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
72 changes: 52 additions & 20 deletions reflect.go
Original file line number Diff line number Diff line change
Expand Up @@ -610,7 +610,12 @@ func (r *Reflector) lookupID(t reflect.Type) ID {
}

func (t *Schema) structKeywordsFromTags(f reflect.StructField, parent *Schema, propertyName string) {
t.Description = f.Tag.Get("jsonschema_description")
// Only override the description when the tag is explicitly provided.
// Unconditionally assigning (even an empty string) would erase descriptions
// set by JSONSchemaExtend on the reflected type.
if desc := f.Tag.Get("jsonschema_description"); desc != "" {
t.Description = desc
}

tags := splitOnUnescapedCommas(f.Tag.Get("jsonschema"))
tags = t.genericKeywords(tags, parent, propertyName)
Expand Down Expand Up @@ -1148,28 +1153,55 @@ func (r *Reflector) typeName(t reflect.Type) string {
return t.Name()
}

// Split on commas that are not preceded by `\`.
// This way, we prevent splitting regexes
// splitOnUnescapedCommas splits a jsonschema tag string on commas that are
// neither backslash-escaped (\,) nor inside curly-brace quantifiers ({m,n}).
//
// Backslash-escaping lets callers include literal commas in simple values.
// Brace-aware splitting lets callers include regex quantifiers such as {0,2}
// inside a pattern= value without truncation. See issues #169 and #181.
func splitOnUnescapedCommas(tagString string) []string {
ret := make([]string, 0)
separated := strings.Split(tagString, ",")
ret = append(ret, separated[0])
i := 0
for _, nextTag := range separated[1:] {
if len(ret[i]) == 0 {
ret = append(ret, nextTag)
i++
continue
}

if ret[i][len(ret[i])-1] == '\\' {
ret[i] = ret[i][:len(ret[i])-1] + "," + nextTag
} else {
ret = append(ret, nextTag)
i++
var (
ret []string
cur strings.Builder
depth int // brace nesting depth
prevIsBackslash bool
)

for _, ch := range tagString {
switch {
case ch == '{':
depth++
cur.WriteRune(ch)
prevIsBackslash = false
case ch == '}':
if depth > 0 {
depth--
}
cur.WriteRune(ch)
prevIsBackslash = false
case ch == ',' && (depth > 0 || prevIsBackslash):
// Comma inside {…} or preceded by backslash: keep as literal.
if prevIsBackslash {
// Strip the escape character that was already written.
s := cur.String()
cur.Reset()
cur.WriteString(s[:len(s)-1])
}
cur.WriteRune(ch)
prevIsBackslash = false
case ch == ',':
ret = append(ret, cur.String())
cur.Reset()
prevIsBackslash = false
case ch == '\\':
cur.WriteRune(ch)
prevIsBackslash = true
default:
cur.WriteRune(ch)
prevIsBackslash = false
}
}

ret = append(ret, cur.String())
return ret
}

Expand Down
52 changes: 52 additions & 0 deletions reflect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,12 @@ func TestSplitOnUnescapedCommas(t *testing.T) {
{`string without commas`, []string{`string without commas`}},
{`ünicode,𐂄,Ж\,П,ᠳ`, []string{`ünicode`, `𐂄`, `Ж,П`, `ᠳ`}},
{`empty,,tag`, []string{`empty`, ``, `tag`}},
// Commas inside {m,n} quantifiers must not split the tag value.
// See https://github.com/invopop/jsonschema/issues/181
{`minLength=1,pattern=^\[A-Z\]{0,2}$,maxLength=50`, []string{`minLength=1`, `pattern=^\[A-Z\]{0,2}$`, `maxLength=50`}},
{`pattern={1,10},title=qty`, []string{`pattern={1,10}`, `title=qty`}},
// Nested braces (e.g. {1,{2,3}}) are treated as depth-aware.
{`pattern={1,{2,3}},title=x`, []string{`pattern={1,{2,3}}`, `title=x`}},
}

for _, test := range tests {
Expand All @@ -535,6 +541,52 @@ func TestSplitOnUnescapedCommas(t *testing.T) {
}
}

// TestDescriptionPreservedFromJSONSchemaExtend verifies that a description set
// via JSONSchemaExtend on a property's type is not silently cleared by
// structKeywordsFromTags when the field has no jsonschema_description tag.
// See https://github.com/invopop/jsonschema/issues/169
func TestDescriptionPreservedFromJSONSchemaExtend(t *testing.T) {
// SchemaExtendTest.JSONSchemaExtend sets LastName.Description = "some extra words".
// LastName has no jsonschema_description tag, so the description must survive.
r := new(Reflector)
schema := r.Reflect(&SchemaExtendTest{})
require.NotNil(t, schema)

def, ok := schema.Definitions["SchemaExtendTest"]
require.True(t, ok, "SchemaExtendTest definition not found")

prop, found := def.Properties.Get("LastName")
require.True(t, found, "LastName property not found")
require.Equal(t, "some extra words", prop.Description,
"description set by JSONSchemaExtend must not be overwritten by empty jsonschema_description tag")
}

// TestPatternWithQuantifierComma verifies that a regex quantifier containing a
// comma (e.g. {0,2}) inside a pattern= tag value is not split at the comma,
// so the full pattern reaches the generated schema.
// See https://github.com/invopop/jsonschema/issues/181
func TestPatternWithQuantifierComma(t *testing.T) {
type QuantifierTest struct {
Code string `json:"code" jsonschema:"minLength=1,pattern=^[A-Z]{0,3}$,maxLength=10"`
}

r := new(Reflector)
schema := r.Reflect(&QuantifierTest{})
require.NotNil(t, schema)

def, ok := schema.Definitions["QuantifierTest"]
require.True(t, ok, "QuantifierTest definition not found")

prop, found := def.Properties.Get("code")
require.True(t, found, "code property not found")
require.Equal(t, "^[A-Z]{0,3}$", prop.Pattern,
"pattern containing {m,n} quantifier must not be truncated at the comma")
require.NotNil(t, prop.MinLength)
require.Equal(t, uint64(1), *prop.MinLength, "minLength must be parsed correctly alongside pattern")
require.NotNil(t, prop.MaxLength)
require.Equal(t, uint64(10), *prop.MaxLength, "maxLength must be parsed correctly alongside pattern")
}

func TestArrayExtraTags(t *testing.T) {
type URIArray struct {
TestURIs []string `jsonschema:"type=array,format=uri,pattern=^https://.*"`
Expand Down