From 68aa8f0574567b468f551ef8e2cbbe0766411ede Mon Sep 17 00:00:00 2001 From: Steve Moyer Date: Wed, 27 Aug 2025 09:57:06 -0400 Subject: [PATCH] feat(type): marshals and unmarshals multivalued type fields Resolves #151 --- fixtures/multivalued_type.json | 17 +++++ reflect.go | 58 +++++++-------- reflect_test.go | 20 +++--- schema.go | 73 ++++++++++++++++++- schema_test.go | 124 +++++++++++++++++++++++++++++++++ 5 files changed, 252 insertions(+), 40 deletions(-) create mode 100644 fixtures/multivalued_type.json create mode 100644 schema_test.go diff --git a/fixtures/multivalued_type.json b/fixtures/multivalued_type.json new file mode 100644 index 0000000..4490bf6 --- /dev/null +++ b/fixtures/multivalued_type.json @@ -0,0 +1,17 @@ +{ + "properties": { + "other": { + "type": "string" + }, + "withdrawals": { + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "withdrawals" + ], + "description": "test of Schema with multivalued property Type" +} \ No newline at end of file diff --git a/reflect.go b/reflect.go index 73ce7e4..a94a8e5 100644 --- a/reflect.go +++ b/reflect.go @@ -305,8 +305,8 @@ func (r *Reflector) reflectTypeToSchema(definitions Definitions, t reflect.Type) // It will unmarshal either. if t.Implements(protoEnumType) { st.OneOf = []*Schema{ - {Type: "string"}, - {Type: "integer"}, + {Type: TypeString}, + {Type: TypeInteger}, } return st } @@ -316,7 +316,7 @@ func (r *Reflector) reflectTypeToSchema(definitions Definitions, t reflect.Type) // TODO email RFC section 7.3.2, hostname RFC section 7.3.3, uriref RFC section 7.3.7 if t == ipType { // TODO differentiate ipv4 and ipv6 RFC section 7.3.4, 7.3.5 - st.Type = "string" + st.Type = TypeString st.Format = "ipv4" return st } @@ -336,16 +336,16 @@ func (r *Reflector) reflectTypeToSchema(definitions Definitions, t reflect.Type) case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - st.Type = "integer" + st.Type = TypeInteger case reflect.Float32, reflect.Float64: - st.Type = "number" + st.Type = TypeNumber case reflect.Bool: - st.Type = "boolean" + st.Type = TypeBoolean case reflect.String: - st.Type = "string" + st.Type = TypeString default: panic("unsupported type " + t.String()) @@ -410,11 +410,11 @@ func (r *Reflector) reflectSliceOrArray(definitions Definitions, t reflect.Type, st.MaxItems = &l } if t.Kind() == reflect.Slice && t.Elem() == byteSliceType.Elem() { - st.Type = "string" + st.Type = TypeString // NOTE: ContentMediaType is not set here st.ContentEncoding = "base64" } else { - st.Type = "array" + st.Type = TypeArray st.Items = r.refOrReflectTypeToSchema(definitions, t.Elem()) } } @@ -422,7 +422,7 @@ func (r *Reflector) reflectSliceOrArray(definitions Definitions, t reflect.Type, func (r *Reflector) reflectMap(definitions Definitions, t reflect.Type, st *Schema) { r.addDefinition(definitions, t, st) - st.Type = "object" + st.Type = TypeObject if st.Description == "" { st.Description = r.lookupComment(t, "") } @@ -445,17 +445,17 @@ func (r *Reflector) reflectStruct(definitions Definitions, t reflect.Type, s *Sc // Handle special types switch t { case timeType: // date-time RFC section 7.3.1 - s.Type = "string" + s.Type = TypeString s.Format = "date-time" return case uriType: // uri RFC section 7.3.6 - s.Type = "string" + s.Type = TypeString s.Format = "uri" return } r.addDefinition(definitions, t, s) - s.Type = "object" + s.Type = TypeObject s.Properties = NewProperties() s.Description = r.lookupComment(t, "") if r.AssignAnchor { @@ -534,7 +534,7 @@ func (r *Reflector) reflectStructFields(st *Schema, definitions Definitions, t r OneOf: []*Schema{ property, { - Type: "null", + Type: TypeNull, }, }, } @@ -612,15 +612,15 @@ func (t *Schema) structKeywordsFromTags(f reflect.StructField, parent *Schema, p tags = t.genericKeywords(tags, parent, propertyName) switch t.Type { - case "string": + case TypeString: t.stringKeywords(tags) - case "number": + case TypeNumber: t.numericalKeywords(tags) - case "integer": + case TypeInteger: t.numericalKeywords(tags) - case "array": + case TypeArray: t.arrayKeywords(tags) - case "boolean": + case TypeBoolean: t.booleanKeywords(tags) } extras := strings.Split(f.Tag.Get("jsonschema_extras"), ",") @@ -640,7 +640,7 @@ func (t *Schema) genericKeywords(tags []string, parent *Schema, propertyName str case "description": t.Description = val case "type": - t.Type = val + t.Type = Type(val) case "anchor": t.Anchor = val case "oneof_required": @@ -692,11 +692,11 @@ func (t *Schema) genericKeywords(tags []string, parent *Schema, propertyName str if t.OneOf == nil { t.OneOf = make([]*Schema, 0, 1) } - t.Type = "" + t.Type = typeUndefined types := strings.Split(nameValue[1], ";") for _, ty := range types { t.OneOf = append(t.OneOf, &Schema{ - Type: ty, + Type: Type(ty), }) } case "anyof_ref": @@ -718,11 +718,11 @@ func (t *Schema) genericKeywords(tags []string, parent *Schema, propertyName str if t.AnyOf == nil { t.AnyOf = make([]*Schema, 0, 1) } - t.Type = "" + t.Type = typeUndefined types := strings.Split(nameValue[1], ";") for _, ty := range types { t.AnyOf = append(t.AnyOf, &Schema{ - Type: ty, + Type: Type(ty), }) } default: @@ -870,15 +870,15 @@ func (t *Schema) arrayKeywords(tags []string) { } switch t.Items.Type { - case "string": + case TypeString: t.Items.stringKeywords(unprocessed) - case "number": + case TypeNumber: t.Items.numericalKeywords(unprocessed) - case "integer": + case TypeInteger: t.Items.numericalKeywords(unprocessed) - case "array": + case TypeArray: // explicitly don't support traversal for the [][]..., as it's unclear where the array tags belong - case "boolean": + case TypeBoolean: t.Items.booleanKeywords(unprocessed) } } diff --git a/reflect_test.go b/reflect_test.go index 93bee67..58d8c87 100644 --- a/reflect_test.go +++ b/reflect_test.go @@ -123,7 +123,7 @@ type CustomTypeFieldWithInterface struct { func (CustomTimeWithInterface) JSONSchema() *Schema { return &Schema{ - Type: "string", + Type: TypeString, Format: "date-time", } } @@ -208,7 +208,7 @@ type UserWithAnchor struct { func (CompactDate) JSONSchema() *Schema { return &Schema{ - Type: "string", + Type: TypeString, Title: "Compact Date", Description: "Short date that only includes year and month", Pattern: "^[0-9]{4}-[0-1][0-9]$", @@ -256,11 +256,11 @@ type CustomSliceType []string func (CustomSliceType) JSONSchema() *Schema { return &Schema{ OneOf: []*Schema{{ - Type: "string", + Type: TypeString, }, { - Type: "array", + Type: TypeArray, Items: &Schema{ - Type: "string", + Type: TypeString, }, }}, } @@ -271,15 +271,15 @@ type CustomMapType map[string]string func (CustomMapType) JSONSchema() *Schema { properties := NewProperties() properties.Set("key", &Schema{ - Type: "string", + Type: TypeString, }) properties.Set("value", &Schema{ - Type: "string", + Type: TypeString, }) return &Schema{ - Type: "array", + Type: TypeArray, Items: &Schema{ - Type: "object", + Type: TypeObject, Properties: properties, Required: []string{"key", "value"}, }, @@ -386,7 +386,7 @@ func TestSchemaGeneration(t *testing.T) { Mapper: func(i reflect.Type) *Schema { if i == reflect.TypeOf(CustomTime{}) { return &Schema{ - Type: "string", + Type: TypeString, Format: "date-time", } } diff --git a/schema.go b/schema.go index 2d914b8..8f16ff8 100644 --- a/schema.go +++ b/schema.go @@ -2,6 +2,7 @@ package jsonschema import ( "encoding/json" + "strings" orderedmap "github.com/wk8/go-ordered-map/v2" ) @@ -40,7 +41,7 @@ type Schema struct { AdditionalProperties *Schema `json:"additionalProperties,omitempty"` // section 10.3.2.3 PropertyNames *Schema `json:"propertyNames,omitempty"` // section 10.3.2.4 // RFC draft-bhutton-json-schema-validation-00, section 6 - Type string `json:"type,omitempty"` // section 6.1.1 + Type Type `json:"type,omitempty"` // section 6.1.1 Enum []any `json:"enum,omitempty"` // section 6.1.2 Const any `json:"const,omitempty"` // section 6.1.3 MultipleOf json.Number `json:"multipleOf,omitempty"` // section 6.2.1 @@ -92,3 +93,73 @@ var ( // http://json-schema.org/latest/json-schema-validation.html#rfc.section.5.26 // RFC draft-wright-json-schema-validation-00, section 5.26 type Definitions map[string]*Schema + +// Type represents the property types allowed by the JSONSchema specification,s +// [instance data model]. +// +// Constants are provided for each of the types supported by this library's +// vocabulary. Types that can be one of several values are supported by +// concatenating the constant values below with a comma separator. The +// MarshalJSON and UnmarshalJSON functions handle the multivalued Type +// fields properly but care must be taken when treating a Type as a string. +// +// [instance data model]: https://json-schema.org/draft/2020-12/json-schema-core#name-instance-data-model +type Type string + +// The Type constants below represent the six standard JSON types as well as +// TypeInteger which is part of this library's extended vocabulary. +const ( + typeUndefined Type = "" + TypeString Type = "string" + TypeInteger Type = "integer" + TypeNumber Type = "number" + TypeBoolean Type = "boolean" + TypeArray Type = "array" + TypeObject Type = "object" + TypeNull Type = "null" +) + +// NewMultivaluedType creates a Type that allows any of the provided types. +func NewMultivaluedType(types ...Type) Type { + var s []string + for _, t := range types { + s = append(s, string(t)) + } + + return Type(strings.Join(s, ",")) +} + +// IsMultivalued returns a boolean indicating whether the Type contains +// multiple underlying types. +func (t *Type) IsMultivalued() bool { + return strings.Contains(string(*t), ",") +} + +// MarshalJSON implements json.Marshaler. +func (t *Type) MarshalJSON() ([]byte, error) { + if t.IsMultivalued() { + return json.Marshal(strings.Split(string(*t), ",")) + } + + return json.Marshal(string(*t)) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (t *Type) UnmarshalJSON(data []byte) error { + var s string + err := json.Unmarshal(data, &s) + if err == nil { + *t = Type(s) + + return nil + } + + var a []string + if err := json.Unmarshal(data, &a); err != nil { + return err + } + + *t = Type(strings.Join(a, ",")) + + return nil +} diff --git a/schema_test.go b/schema_test.go new file mode 100644 index 0000000..b2f9e95 --- /dev/null +++ b/schema_test.go @@ -0,0 +1,124 @@ +package jsonschema + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ethExample = ` +{ + "BlobsBundleV2": { + "properties": { + "blobs": { + "items": { + "$ref": "#/components/schemas/bytes" + }, + "title": "Blobs", + "type": "array" + }, + "commitments": { + "items": { + "$ref": "#/components/schemas/bytes48" + }, + "title": "Commitments", + "type": "array" + }, + "proofs": { + "items": { + "$ref": "#/components/schemas/bytes48" + }, + "title": "Proofs", + "type": "array" + } + }, + "required": [ + "commitments", + "proofs", + "blobs" + ], + "title": "Blobs bundle object V2", + "type": "object" + }, + "ExecutionPayloadBodyV1": { + "properties": { + "transactions": { + "$ref": "#/components/schemas/ExecutionPayloadV1/properties/transactions" + }, + "withdrawals": { + "items": { + "$ref": "#/components/schemas/WithdrawalV1" + }, + "title": "Withdrawals", + "type": [ + "array", + "null" + ] + } + }, + "required": [ + "transactions" + ], + "title": "Execution payload body object V1", + "type": "object" + } +} +` + +func TestType_MarshalJSON(t *testing.T) { + t.Parallel() + + filename := filepath.Join("fixtures", "multivalued_type.json") + + expectedJSON, err := os.ReadFile(filename) + require.NoError(t, err) + + props := NewProperties() + props.Set("other", &Schema{ + Type: TypeString, + }) + props.Set("withdrawals", &Schema{ + Type: NewMultivaluedType(TypeArray, TypeNull), + }) + + schema := &Schema{ + Description: "test of Schema with multivalued property Type", + Properties: props, + Required: []string{"withdrawals"}, + } + + actualJSON, err := json.MarshalIndent(schema, "", " ") + require.NoError(t, err) + + if *updateFixtures { + _ = os.WriteFile(filename, actualJSON, 0600) + } + + if !assert.JSONEq(t, string(expectedJSON), string(actualJSON)) { + if *compareFixtures { + _ = os.WriteFile(strings.TrimSuffix(filename, ".json")+".out.json", actualJSON, 0600) + } + } +} + +func TestType_UnmarshalJSON(t *testing.T) { + t.Parallel() + + var defs Definitions + require.NoError(t, json.Unmarshal([]byte(ethExample), &defs)) + + withdrawals, ok := defs["ExecutionPayloadBodyV1"].Properties.Get("withdrawals") + require.True(t, ok) + assert.True(t, withdrawals.Type.IsMultivalued()) + assert.Equal(t, NewMultivaluedType(TypeArray, TypeNull), withdrawals.Type) + + proofs, ok := defs["BlobsBundleV2"].Properties.Get("proofs") + require.True(t, ok) + assert.False(t, proofs.Type.IsMultivalued()) + assert.Equal(t, TypeArray, proofs.Type) +}