Skip to content
Open
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
171 changes: 128 additions & 43 deletions reflect.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ package jsonschema
import (
"bytes"
"encoding/json"
"fmt"
"net"
"net/url"
"reflect"
Expand Down Expand Up @@ -295,8 +296,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: []string{"string"}},
{Type: []string{"integer"}},
}
return st
}
Expand All @@ -306,7 +307,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 = []string{"string"}
st.Format = "ipv4"
return st
}
Expand All @@ -326,16 +327,24 @@ 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"
if len(st.Type) == 0 {
st.Type = []string{"integer"}
}

case reflect.Float32, reflect.Float64:
st.Type = "number"
if len(st.Type) == 0 {
st.Type = []string{"number"}
}

case reflect.Bool:
st.Type = "boolean"
if len(st.Type) == 0 {
st.Type = []string{"boolean"}
}

case reflect.String:
st.Type = "string"
if len(st.Type) == 0 {
st.Type = []string{"string"}
}

default:
panic("unsupported type " + t.String())
Expand Down Expand Up @@ -399,20 +408,23 @@ func (r *Reflector) reflectSliceOrArray(definitions Definitions, t reflect.Type,
st.MinItems = &l
st.MaxItems = &l
}
if t.Kind() == reflect.Slice && t.Elem() == byteSliceType.Elem() {
st.Type = "string"
// NOTE: ContentMediaType is not set here
st.ContentEncoding = "base64"
} else {
st.Type = "array"
st.Items = r.refOrReflectTypeToSchema(definitions, t.Elem())
if len(st.Type) == 0 {
if t.Kind() == reflect.Slice && t.Elem() == byteSliceType.Elem() {
st.Type = []string{"string"}
// NOTE: ContentMediaType is not set here
st.ContentEncoding = "base64"
} else {
st.Type = []string{"array"}
st.Items = r.refOrReflectTypeToSchema(definitions, t.Elem())
}
}
}

func (r *Reflector) reflectMap(definitions Definitions, t reflect.Type, st *Schema) {
r.addDefinition(definitions, t, st)

st.Type = "object"
if len(st.Type) == 0 {
st.Type = []string{"object"}
}
if st.Description == "" {
st.Description = r.lookupComment(t, "")
}
Expand All @@ -435,17 +447,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 = []string{"string"}
s.Format = "date-time"
return
case uriType: // uri RFC section 7.3.6
s.Type = "string"
s.Type = []string{"string"}
s.Format = "uri"
return
}

r.addDefinition(definitions, t, s)
s.Type = "object"
s.Type = []string{"object"}
s.Properties = NewProperties()
s.Description = r.lookupComment(t, "")
if r.AssignAnchor {
Expand Down Expand Up @@ -504,27 +516,30 @@ func (r *Reflector) reflectStructFields(st *Schema, definitions Definitions, t r

// If a JSONSchemaAlias(prop string) method is defined, attempt to use
// the provided object's type instead of the field's type.
var property *Schema
property := new(Schema)
property.structKeywordsFromTags(f, st, name)
var reflectedProperty *Schema
if alias := customPropertyMethod(name); alias != nil {
property = r.refOrReflectTypeToSchema(definitions, reflect.TypeOf(alias))
reflectedProperty = r.refOrReflectTypeToSchema(definitions, reflect.TypeOf(alias))
} else {
property = r.refOrReflectTypeToSchema(definitions, f.Type)
reflectedProperty = r.refOrReflectTypeToSchema(definitions, f.Type)
}

property.structKeywordsFromTags(f, st, name)
if property.Description == "" {
property.Description = r.lookupComment(t, f.Name)
}
if getFieldDocString != nil {
property.Description = getFieldDocString(f.Name)
}
mergeSchemas(property, reflectedProperty)

if nullable {
property = &Schema{
OneOf: []*Schema{
property,
{
Type: "null",
Type: []string{"null"},
},
},
}
Expand All @@ -549,6 +564,28 @@ func (r *Reflector) reflectStructFields(st *Schema, definitions Definitions, t r
}
}

func mergeSchemas(dst, src *Schema) {
if len(dst.Type) == 0 {
dst.Type = src.Type
}
if dst.Format == "" {
dst.Format = src.Format
}
if dst.Pattern == "" {
dst.Pattern = src.Pattern
}
if dst.Items == nil {
dst.Items = src.Items
}
if dst.Properties == nil {
dst.Properties = src.Properties
}
if dst.AdditionalProperties == nil {
dst.AdditionalProperties = src.AdditionalProperties
}
// TODO: Merge other fields as needed
}

func appendUniqueString(base []string, value string) []string {
for _, v := range base {
if v == value {
Expand Down Expand Up @@ -613,18 +650,19 @@ func (t *Schema) structKeywordsFromTags(f reflect.StructField, parent *Schema, p

tags := splitOnUnescapedCommas(f.Tag.Get("jsonschema"))
tags = t.genericKeywords(tags, parent, propertyName)

switch t.Type {
case "string":
t.stringKeywords(tags)
case "number":
t.numericalKeywords(tags)
case "integer":
t.numericalKeywords(tags)
case "array":
t.arrayKeywords(tags)
case "boolean":
t.booleanKeywords(tags)
for _, typ := range t.Type {
switch typ {
case "string":
t.stringKeywords(tags)
case "number":
t.numericalKeywords(tags)
case "integer":
t.numericalKeywords(tags)
case "array":
t.arrayKeywords(tags)
case "boolean":
t.booleanKeywords(tags)
}
}
extras := strings.Split(f.Tag.Get("jsonschema_extras"), ",")
t.extraKeywords(extras)
Expand All @@ -643,7 +681,9 @@ func (t *Schema) genericKeywords(tags []string, parent *Schema, propertyName str
case "description":
t.Description = val
case "type":
t.Type = val
t.Type = append(t.Type, strings.Split(val, ";")...)
case "types":
t.Type = append(t.Type, strings.Split(val, ";")...)
case "anchor":
t.Anchor = val
case "oneof_required":
Expand Down Expand Up @@ -695,11 +735,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 = []string{""}
types := strings.Split(nameValue[1], ";")
for _, ty := range types {
t.OneOf = append(t.OneOf, &Schema{
Type: ty,
Type: []string{ty},
})
}
case "anyof_ref":
Expand All @@ -721,11 +761,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 = []string{""}
types := strings.Split(nameValue[1], ";")
for _, ty := range types {
t.AnyOf = append(t.AnyOf, &Schema{
Type: ty,
Type: []string{ty},
})
}
default:
Expand Down Expand Up @@ -872,7 +912,7 @@ func (t *Schema) arrayKeywords(tags []string) {
return
}

switch t.Items.Type {
switch t.Items.Type[0] {
case "string":
t.Items.stringKeywords(unprocessed)
case "number":
Expand Down Expand Up @@ -1072,13 +1112,41 @@ func (t *Schema) UnmarshalJSON(data []byte) error {
*t = *FalseSchema
return nil
}

type SchemaAlt Schema
aux := &struct {
Type interface{} `json:"type,omitempty"`
*SchemaAlt
}{
SchemaAlt: (*SchemaAlt)(t),
}
return json.Unmarshal(data, aux)

if err := json.Unmarshal(data, aux); err != nil {
return err
}

// Handle the 'type' field
switch v := aux.Type.(type) {
case string:
t.Type = []string{v}
case []interface{}:
var types []string
for _, item := range v {
if s, ok := item.(string); ok {
types = append(types, s)
} else {
return fmt.Errorf("invalid type value: must be a string")
}
}
t.Type = types
case nil:
// Type is omitted or null, set to nil
t.Type = nil
default:
return fmt.Errorf("invalid type value")
}

return nil
}

// MarshalJSON is used to serialize a schema object or boolean.
Expand All @@ -1093,8 +1161,25 @@ func (t *Schema) MarshalJSON() ([]byte, error) {
// Don't bother returning empty schemas
return []byte("true"), nil
}
// Prepare the Type field for marshalling
var typeField interface{}
switch len(t.Type) {
case 0:
typeField = nil // Omit the "type" field
case 1:
typeField = t.Type[0] // Use a single string
default:
typeField = t.Type // Use the slice as-is
}
type SchemaAlt Schema
b, err := json.Marshal((*SchemaAlt)(t))
tempSchema := &struct {
*SchemaAlt
Type interface{} `json:"type,omitempty"`
}{
SchemaAlt: (*SchemaAlt)(t),
Type: typeField,
}
b, err := json.Marshal(tempSchema)
if err != nil {
return nil, err
}
Expand Down
20 changes: 10 additions & 10 deletions reflect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ type CustomTypeFieldWithInterface struct {

func (CustomTimeWithInterface) JSONSchema() *Schema {
return &Schema{
Type: "string",
Type: []string{"string"},
Format: "date-time",
}
}
Expand Down Expand Up @@ -210,7 +210,7 @@ type UserWithAnchor struct {

func (CompactDate) JSONSchema() *Schema {
return &Schema{
Type: "string",
Type: []string{"string"},
Title: "Compact Date",
Description: "Short date that only includes year and month",
Pattern: "^[0-9]{4}-[0-1][0-9]$",
Expand Down Expand Up @@ -258,11 +258,11 @@ type CustomSliceType []string
func (CustomSliceType) JSONSchema() *Schema {
return &Schema{
OneOf: []*Schema{{
Type: "string",
Type: []string{"string"},
}, {
Type: "array",
Type: []string{"array"},
Items: &Schema{
Type: "string",
Type: []string{"string"},
},
}},
}
Expand All @@ -273,15 +273,15 @@ type CustomMapType map[string]string
func (CustomMapType) JSONSchema() *Schema {
properties := NewProperties()
properties.Set("key", &Schema{
Type: "string",
Type: []string{"string"},
})
properties.Set("value", &Schema{
Type: "string",
Type: []string{"string"},
})
return &Schema{
Type: "array",
Type: []string{"array"},
Items: &Schema{
Type: "object",
Type: []string{"object"},
Properties: properties,
Required: []string{"key", "value"},
},
Expand Down Expand Up @@ -388,7 +388,7 @@ func TestSchemaGeneration(t *testing.T) {
Mapper: func(i reflect.Type) *Schema {
if i == reflect.TypeOf(CustomTime{}) {
return &Schema{
Type: "string",
Type: []string{"string"},
Format: "date-time",
}
}
Expand Down
2 changes: 1 addition & 1 deletion schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,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 []string `json:"type,omitempty"` // section 6.1.1
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is a significant breaking change for anyone using the Type field directly, and as such, I'm not convinced this is the correct approach.

I'm wondering if an alternative approach, given how rare this field is even used, might be to automatically handle a comma or semicolon separated list of types alongside the automatic conversion in the JSON marshalling methods.

A definition would effectively look like:

jsonschema.Schema{
  Type: "string,null",
}

or in a struct:

type Object struct {
  Text string `jsonschema:"type=string,null"`
}

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the structure, we could even get it from the property type or the json tag. If it is a pointer or the json tag has the omitempty the null could be added to the jsonchema array

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@festo recognizing the pointer would be the easiest and most go-friendly. I like the idea!

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
Expand Down