Repository: goadesign/goa
Pull request #2727 (2020-11-26 16:13:48)

feature: adding ExclusiveMinimum and ExclusiveMaximum
PR of issue #2726

raphael: This is great, thank you!

Patch:
diff --git a/codegen/validation.go b/codegen/validation.go
index ad6dff88..64836b43 100644
--- a/codegen/validation.go
+++ b/codegen/validation.go
@@ -11,15 +11,16 @@ import (
 )
 
 var (
-	enumValT     *template.Template
-	formatValT   *template.Template
-	patternValT  *template.Template
-	minMaxValT   *template.Template
-	lengthValT   *template.Template
-	requiredValT *template.Template
-	arrayValT    *template.Template
-	mapValT      *template.Template
-	userValT     *template.Template
+	enumValT       *template.Template
+	formatValT     *template.Template
+	patternValT    *template.Template
+	exclMinMaxValT *template.Template
+	minMaxValT     *template.Template
+	lengthValT     *template.Template
+	requiredValT   *template.Template
+	arrayValT      *template.Template
+	mapValT        *template.Template
+	userValT       *template.Template
 )
 
 func init() {
@@ -33,6 +34,7 @@ func init() {
 	enumValT = template.Must(template.New("enum").Funcs(fm).Parse(enumValTmpl))
 	formatValT = template.Must(template.New("format").Funcs(fm).Parse(formatValTmpl))
 	patternValT = template.Must(template.New("pattern").Funcs(fm).Parse(patternValTmpl))
+	exclMinMaxValT = template.Must(template.New("exclMinMax").Funcs(fm).Parse(exclMinMaxValTmpl))
 	minMaxValT = template.Must(template.New("minMax").Funcs(fm).Parse(minMaxValTmpl))
 	lengthValT = template.Must(template.New("length").Funcs(fm).Parse(lengthValTmpl))
 	requiredValT = template.Must(template.New("req").Funcs(fm).Parse(requiredValTmpl))
@@ -112,6 +114,14 @@ func ValidationCode(att *expr.AttributeExpr, attCtx *AttributeContext, req bool,
 			res = append(res, val)
 		}
 	}
+	if exclMin := validation.ExclusiveMinimum; exclMin != nil {
+		data["exclMin"] = *exclMin
+		data["isExclMin"] = true
+		delete(data, "exclMax")
+		if val := runTemplate(exclMinMaxValT, data); val != "" {
+			res = append(res, val)
+		}
+	}
 	if min := validation.Minimum; min != nil {
 		data["min"] = *min
 		data["isMin"] = true
@@ -120,6 +130,14 @@ func ValidationCode(att *expr.AttributeExpr, attCtx *AttributeContext, req bool,
 			res = append(res, val)
 		}
 	}
+	if exclMax := validation.ExclusiveMaximum; exclMax != nil {
+		data["exclMax"] = *exclMax
+		data["isExclMax"] = true
+		delete(data, "exclMax")
+		if val := runTemplate(exclMinMaxValT, data); val != "" {
+			res = append(res, val)
+		}
+	}
 	if max := validation.Maximum; max != nil {
 		data["max"] = *max
 		data["isMin"] = false
@@ -476,6 +494,18 @@ if {{ .target }} != nil {
 }
 {{- end }}`
 
+	exclMinMaxValTmpl = `{{ if isset .zeroVal -}}
+if {{ .target }} != {{ if and (not .zeroVal) .string }}""{{ else }}{{ .zeroVal }}{{ end }} {
+{{ else if .isPointer -}}
+if {{ .target }} != nil {
+{{ end -}}
+        if {{ .targetVal }} {{ if .isExclMin }}<{{ else }}>{{ end }} {{ if .isExclMin }}{{ .exclMin }}{{ else }}{{ .exclMax }}{{ end }} {
+        err = goa.MergeErrors(err, goa.InvalidRangeError({{ printf "%q" .context }}, {{ .targetVal }}, {{ if .isExclMin }}{{ .exclMin }}, true{{ else }}{{ .exclMax }}, false{{ end }}))
+{{ if or (isset .zeroVal) .isPointer -}}
+}
+{{ end -}}
+}`
+
 	minMaxValTmpl = `{{ if isset .zeroVal -}}
 if {{ .target }} != {{ if and (not .zeroVal) .string }}""{{ else }}{{ .zeroVal }}{{ end }} {
 {{ else if .isPointer -}}
diff --git a/dsl/validation.go b/dsl/validation.go
index 1405ae36..5b24e4e3 100644
--- a/dsl/validation.go
+++ b/dsl/validation.go
@@ -190,6 +190,48 @@ func Pattern(p string) {
 	}
 }
 
+// ExclusiveMinimum adds a "exclusiveMinimum" validation to the attribute.
+// See http://json-schema.org/draft/2019-09/json-schema-validation.html#rfc.section.6.2.5.
+//
+// Example:
+//
+//    Attribute("float", float32, func() {
+//        ExclusiveMinimum(100)
+//    })
+//
+func ExclusiveMinimum(val interface{}) {
+	if a, ok := eval.Current().(*expr.AttributeExpr); ok {
+		if a.Type != nil &&
+			a.Type.Kind() != expr.IntKind && a.Type.Kind() != expr.UIntKind &&
+			a.Type.Kind() != expr.Int32Kind && a.Type.Kind() != expr.UInt32Kind &&
+			a.Type.Kind() != expr.Int64Kind && a.Type.Kind() != expr.UInt64Kind &&
+			a.Type.Kind() != expr.Float32Kind && a.Type.Kind() != expr.Float64Kind {
+
+			incompatibleAttributeType("exclusiveMinimum", a.Type.Name(), "a number")
+		} else {
+			var f float64
+			switch v := val.(type) {
+			case float32, float64, int, int8, int16, int32, int64, uint8, uint16, uint32, uint64:
+				f = reflect.ValueOf(v).Convert(reflect.TypeOf(float64(0.0))).Float()
+			case string:
+				var err error
+				f, err = strconv.ParseFloat(v, 64)
+				if err != nil {
+					eval.ReportError("invalid number value %#v", v)
+					return
+				}
+			default:
+				eval.ReportError("invalid number value %#v", v)
+				return
+			}
+			if a.Validation == nil {
+				a.Validation = &expr.ValidationExpr{}
+			}
+			a.Validation.ExclusiveMinimum = &f
+		}
+	}
+}
+
 // Minimum adds a "minimum" validation to the attribute.
 // See http://json-schema.org/latest/json-schema-validation.html#anchor21.
 //
@@ -207,7 +249,7 @@ func Minimum(val interface{}) {
 			a.Type.Kind() != expr.Int64Kind && a.Type.Kind() != expr.UInt64Kind &&
 			a.Type.Kind() != expr.Float32Kind && a.Type.Kind() != expr.Float64Kind {
 
-			incompatibleAttributeType("minimum", a.Type.Name(), "an integer or a number")
+			incompatibleAttributeType("minimum", a.Type.Name(), "a number")
 		} else {
 			var f float64
 			switch v := val.(type) {
@@ -232,6 +274,48 @@ func Minimum(val interface{}) {
 	}
 }
 
+// ExclusiveMaximum adds a "exclusiveMaximum" validation to the attribute.
+// See http://json-schema.org/draft/2019-09/json-schema-validation.html#rfc.section.6.2.3.
+//
+// Example:
+//
+//    Attribute("float", float32, func() {
+//        ExclusiveMaximum(100)
+//    })
+//
+func ExclusiveMaximum(val interface{}) {
+	if a, ok := eval.Current().(*expr.AttributeExpr); ok {
+		if a.Type != nil &&
+			a.Type.Kind() != expr.IntKind && a.Type.Kind() != expr.UIntKind &&
+			a.Type.Kind() != expr.Int32Kind && a.Type.Kind() != expr.UInt32Kind &&
+			a.Type.Kind() != expr.Int64Kind && a.Type.Kind() != expr.UInt64Kind &&
+			a.Type.Kind() != expr.Float32Kind && a.Type.Kind() != expr.Float64Kind {
+
+			incompatibleAttributeType("exclusiveMaximum", a.Type.Name(), "a number")
+		} else {
+			var f float64
+			switch v := val.(type) {
+			case float32, float64, int, int8, int16, int32, int64, uint8, uint16, uint32, uint64:
+				f = reflect.ValueOf(v).Convert(reflect.TypeOf(float64(0.0))).Float()
+			case string:
+				var err error
+				f, err = strconv.ParseFloat(v, 64)
+				if err != nil {
+					eval.ReportError("invalid number value %#v", v)
+					return
+				}
+			default:
+				eval.ReportError("invalid number value %#v", v)
+				return
+			}
+			if a.Validation == nil {
+				a.Validation = &expr.ValidationExpr{}
+			}
+			a.Validation.ExclusiveMaximum = &f
+		}
+	}
+}
+
 // Maximum adds a "maximum" validation to the attribute.
 // See http://json-schema.org/latest/json-schema-validation.html#anchor17.
 //
diff --git a/expr/attribute.go b/expr/attribute.go
index 5be0dd85..13827f20 100644
--- a/expr/attribute.go
+++ b/expr/attribute.go
@@ -69,6 +69,10 @@ type (
 		// described at
 		// http://json-schema.org/latest/json-schema-validation.html#anchor33
 		Pattern string
+		// ExclusiveMinimum represents an exclusiveMinimum value validation as described
+		// at
+		// http://json-schema.org/draft/2019-09/json-schema-validation.html#rfc.section.6.2.5.
+		ExclusiveMinimum *float64
 		// Minimum represents an minimum value validation as described
 		// at
 		// http://json-schema.org/latest/json-schema-validation.html#anchor21.
@@ -76,6 +80,10 @@ type (
 		// Maximum represents a maximum value validation as described at
 		// http://json-schema.org/latest/json-schema-validation.html#anchor17.
 		Maximum *float64
+		// ExclusiveMaximum represents an exclusiveMaximum value validation as described
+		// at
+		// http://json-schema.org/draft/2019-09/json-schema-validation.html#rfc.section.6.2.3.
+		ExclusiveMaximum *float64
 		// MinLength represents an minimum length validation as
 		// described at
 		// http://json-schema.org/latest/json-schema-validation.html#anchor29.
@@ -670,9 +678,15 @@ func (v *ValidationExpr) Merge(other *ValidationExpr) {
 	if v.Pattern == "" {
 		v.Pattern = other.Pattern
 	}
+	if v.ExclusiveMinimum == nil || (other.ExclusiveMinimum != nil && *v.ExclusiveMinimum > *other.ExclusiveMinimum) {
+		v.ExclusiveMinimum = other.ExclusiveMinimum
+	}
 	if v.Minimum == nil || (other.Minimum != nil && *v.Minimum > *other.Minimum) {
 		v.Minimum = other.Minimum
 	}
+	if v.ExclusiveMaximum == nil || (other.ExclusiveMaximum != nil && *v.ExclusiveMaximum > *other.ExclusiveMaximum) {
+		v.ExclusiveMaximum = other.ExclusiveMaximum
+	}
 	if v.Maximum == nil || (other.Maximum != nil && *v.Maximum < *other.Maximum) {
 		v.Maximum = other.Maximum
 	}
@@ -720,7 +734,12 @@ func (v *ValidationExpr) HasRequiredOnly() bool {
 	if v.Format != "" || v.Pattern != "" {
 		return false
 	}
-	if (v.Minimum != nil) || (v.Maximum != nil) || (v.MinLength != nil) || (v.MaxLength != nil) {
+	if (v.ExclusiveMinimum != nil) ||
+		(v.Minimum != nil) ||
+		(v.ExclusiveMaximum != nil) ||
+		(v.Maximum != nil) ||
+		(v.MinLength != nil) ||
+		(v.MaxLength != nil) {
 		return false
 	}
 	return true
@@ -734,14 +753,16 @@ func (v *ValidationExpr) Dup() *ValidationExpr {
 		copy(req, v.Required)
 	}
 	return &ValidationExpr{
-		Values:    v.Values,
-		Format:    v.Format,
-		Pattern:   v.Pattern,
-		Minimum:   v.Minimum,
-		Maximum:   v.Maximum,
-		MinLength: v.MinLength,
-		MaxLength: v.MaxLength,
-		Required:  req,
+		Values:           v.Values,
+		Format:           v.Format,
+		Pattern:          v.Pattern,
+		ExclusiveMinimum: v.ExclusiveMinimum,
+		Minimum:          v.Minimum,
+		ExclusiveMaximum: v.ExclusiveMaximum,
+		Maximum:          v.Maximum,
+		MinLength:        v.MinLength,
+		MaxLength:        v.MaxLength,
+		Required:         req,
 	}
 }
 
@@ -758,9 +779,15 @@ func (v *ValidationExpr) Debug(indent int) {
 	if v.Pattern != "" {
 		fmt.Printf("%s- pattern: %s\n", prefix, v.Pattern)
 	}
+	if v.ExclusiveMinimum != nil {
+		fmt.Printf("%s- exclMin: %v\n", prefix, *v.ExclusiveMinimum)
+	}
 	if v.Minimum != nil {
 		fmt.Printf("%s- min: %v\n", prefix, *v.Minimum)
 	}
+	if v.ExclusiveMaximum != nil {
+		fmt.Printf("%s- exclMax: %v\n", prefix, *v.ExclusiveMaximum)
+	}
 	if v.Maximum != nil {
 		fmt.Printf("%s- max: %v\n", prefix, *v.Maximum)
 	}
diff --git a/expr/example.go b/expr/example.go
index 40927815..4711bbbb 100644
--- a/expr/example.go
+++ b/expr/example.go
@@ -132,7 +132,10 @@ func hasMinMaxValidation(a *AttributeExpr) bool {
 	if a.Validation == nil {
 		return false
 	}
-	return a.Validation.Minimum != nil || a.Validation.Maximum != nil
+	return a.Validation.ExclusiveMinimum != nil ||
+		a.Validation.Minimum != nil ||
+		a.Validation.ExclusiveMaximum != nil ||
+		a.Validation.Maximum != nil
 }
 
 // byLength generates a random size array of examples based on what's given.
@@ -236,10 +239,14 @@ func byMinMax(a *AttributeExpr, r *Random) interface{} {
 		max  = math.Inf(1)
 		sign = 1
 	)
-	if a.Validation.Maximum != nil {
+	if a.Validation.ExclusiveMaximum != nil {
+		max = *a.Validation.ExclusiveMaximum
+	} else if a.Validation.Maximum != nil {
 		max = *a.Validation.Maximum
 	}
-	if a.Validation.Minimum != nil {
+	if a.Validation.ExclusiveMinimum != nil {
+		min = *a.Validation.ExclusiveMinimum
+	} else if a.Validation.Minimum != nil {
 		min = *a.Validation.Minimum
 	} else {
 		sign = -1
@@ -327,14 +334,28 @@ func checkMinMaxValue(a *AttributeExpr, example interface{}) bool {
 	if !hasMinMaxValidation(a) {
 		return true
 	}
-	if min := a.Validation.Minimum; min != nil {
+	var min *float64
+	if a.Validation.ExclusiveMinimum != nil {
+		min = a.Validation.ExclusiveMinimum
+	}
+	if a.Validation.Minimum != nil {
+		min = a.Validation.Minimum
+	}
+	if min != nil {
 		if v, ok := example.(int); ok && float64(v) < *min {
 			return false
 		} else if v, ok := example.(float64); ok && v < *min {
 			return false
 		}
 	}
-	if max := a.Validation.Maximum; max != nil {
+	var max *float64
+	if a.Validation.ExclusiveMaximum != nil {
+		max = a.Validation.ExclusiveMaximum
+	}
+	if a.Validation.Maximum != nil {
+		max = a.Validation.Maximum
+	}
+	if max != nil {
 		if v, ok := example.(int); ok && float64(v) > *max {
 			return false
 		} else if v, ok := example.(float64); ok && v > *max {
diff --git a/http/codegen/openapi/json_schema.go b/http/codegen/openapi/json_schema.go
index 43d4f9b5..946a4046 100644
--- a/http/codegen/openapi/json_schema.go
+++ b/http/codegen/openapi/json_schema.go
@@ -36,7 +36,9 @@ type (
 		Enum                 []interface{} `json:"enum,omitempty" yaml:"enum,omitempty"`
 		Format               string        `json:"format,omitempty" yaml:"format,omitempty"`
 		Pattern              string        `json:"pattern,omitempty" yaml:"pattern,omitempty"`
+		ExclusiveMinimum     *float64      `json:"exclusiveMinimum,omitempty" yaml:"exclusiveMinimum,omitempty"`
 		Minimum              *float64      `json:"minimum,omitempty" yaml:"minimum,omitempty"`
+		ExclusiveMaximum     *float64      `json:"exclusiveMaximum,omitempty" yaml:"exclusiveMaximum,omitempty"`
 		Maximum              *float64      `json:"maximum,omitempty" yaml:"maximum,omitempty"`
 		MinLength            *int          `json:"minLength,omitempty" yaml:"minLength,omitempty"`
 		MaxLength            *int          `json:"maxLength,omitempty" yaml:"maxLength,omitempty"`
@@ -468,9 +470,15 @@ func initAttributeValidation(s *Schema, at *expr.AttributeExpr) {
 	s.Enum = val.Values
 	s.Format = string(val.Format)
 	s.Pattern = val.Pattern
+	if val.ExclusiveMinimum != nil {
+		s.ExclusiveMinimum = val.ExclusiveMinimum
+	}
 	if val.Minimum != nil {
 		s.Minimum = val.Minimum
 	}
+	if val.ExclusiveMaximum != nil {
+		s.ExclusiveMaximum = val.ExclusiveMaximum
+	}
 	if val.Maximum != nil {
 		s.Maximum = val.Maximum
 	}
diff --git a/http/codegen/openapi/v2/builder.go b/http/codegen/openapi/v2/builder.go
index fbc547ec..a2a58706 100644
--- a/http/codegen/openapi/v2/builder.go
+++ b/http/codegen/openapi/v2/builder.go
@@ -717,6 +717,20 @@ func initPatternValidation(def interface{}, pattern string) {
 	}
 }
 
+func initExclusiveMinimumValidation(def interface{}, exclMin *float64) {
+	switch actual := def.(type) {
+	case *Parameter:
+		actual.Minimum = exclMin
+		actual.ExclusiveMinimum = true
+	case *Header:
+		actual.Minimum = exclMin
+		actual.ExclusiveMinimum = true
+	case *Items:
+		actual.Minimum = exclMin
+		actual.ExclusiveMinimum = true
+	}
+}
+
 func initMinimumValidation(def interface{}, min *float64) {
 	switch actual := def.(type) {
 	case *Parameter:
@@ -731,6 +745,20 @@ func initMinimumValidation(def interface{}, min *float64) {
 	}
 }
 
+func initExclusiveMaximumValidation(def interface{}, exclMax *float64) {
+	switch actual := def.(type) {
+	case *Parameter:
+		actual.Maximum = exclMax
+		actual.ExclusiveMaximum = true
+	case *Header:
+		actual.Maximum = exclMax
+		actual.ExclusiveMaximum = true
+	case *Items:
+		actual.Maximum = exclMax
+		actual.ExclusiveMaximum = true
+	}
+}
+
 func initMaximumValidation(def interface{}, max *float64) {
 	switch actual := def.(type) {
 	case *Parameter:
@@ -783,9 +811,15 @@ func initValidations(attr *expr.AttributeExpr, def interface{}) {
 	initEnumValidation(def, val.Values)
 	initFormatValidation(def, string(val.Format))
 	initPatternValidation(def, val.Pattern)
+	if val.ExclusiveMinimum != nil {
+		initExclusiveMinimumValidation(def, val.ExclusiveMinimum)
+	}
 	if val.Minimum != nil {
 		initMinimumValidation(def, val.Minimum)
 	}
+	if val.ExclusiveMaximum != nil {
+		initExclusiveMaximumValidation(def, val.ExclusiveMaximum)
+	}
 	if val.Maximum != nil {
 		initMaximumValidation(def, val.Maximum)
 	}
diff --git a/http/codegen/openapi/v3/types.go b/http/codegen/openapi/v3/types.go
index 9e92a4ca..63e251c5 100644
--- a/http/codegen/openapi/v3/types.go
+++ b/http/codegen/openapi/v3/types.go
@@ -216,9 +216,15 @@ func (sf *schemafier) schemafy(attr *expr.AttributeExpr) *openapi.Schema {
 	s.Enum = val.Values
 	s.Format = string(val.Format)
 	s.Pattern = val.Pattern
+	if val.ExclusiveMinimum != nil {
+		s.ExclusiveMinimum = val.ExclusiveMinimum
+	}
 	if val.Minimum != nil {
 		s.Minimum = val.Minimum
 	}
+	if val.ExclusiveMaximum != nil {
+		s.ExclusiveMaximum = val.ExclusiveMaximum
+	}
 	if val.Maximum != nil {
 		s.Maximum = val.Maximum
 	}


Test patch:
diff --git a/expr/attribute_test.go b/expr/attribute_test.go
index e2cb1307..9f53b5ae 100644
--- a/expr/attribute_test.go
+++ b/expr/attribute_test.go
@@ -719,22 +719,26 @@ func TestAttributeExprHasDefaultValue(t *testing.T) {
 
 func TestValidationExprHasRequiredOnly(t *testing.T) {
 	var (
-		values    = []interface{}{"foo"}
-		pattern   = "^foo$"
-		minimum   = 1.1
-		maximum   = 2.2
-		minLength = 2
-		maxLength = 3
+		values           = []interface{}{"foo"}
+		pattern          = "^foo$"
+		exclusiveMinimum = 1.1
+		minimum          = 1.1
+		exclusiveMaximum = 2.2
+		maximum          = 2.2
+		minLength        = 2
+		maxLength        = 3
 	)
 	cases := map[string]struct {
-		values    []interface{}
-		format    ValidationFormat
-		pattern   string
-		minimum   *float64
-		maximum   *float64
-		minLength *int
-		maxLength *int
-		expected  bool
+		values           []interface{}
+		format           ValidationFormat
+		pattern          string
+		exclusiveMinimum *float64
+		minimum          *float64
+		exclusiveMaximum *float64
+		maximum          *float64
+		minLength        *int
+		maxLength        *int
+		expected         bool
 	}{
 		"has required only": {
 			expected: true,
@@ -751,10 +755,18 @@ func TestValidationExprHasRequiredOnly(t *testing.T) {
 			pattern:  pattern,
 			expected: false,
 		},
+		"exclusiveMinimum is not nil": {
+			exclusiveMinimum: &exclusiveMinimum,
+			expected:         false,
+		},
 		"minimum is not nil": {
 			minimum:  &minimum,
 			expected: false,
 		},
+		"exclusiveMaximum is not nil": {
+			exclusiveMaximum: &exclusiveMaximum,
+			expected:         false,
+		},
 		"maximum is not nil": {
 			maximum:  &maximum,
 			expected: false,
@@ -768,26 +780,30 @@ func TestValidationExprHasRequiredOnly(t *testing.T) {
 			expected:  false,
 		},
 		"complex validation": {
-			values:    values,
-			format:    FormatDate,
-			pattern:   pattern,
-			minimum:   &minimum,
-			maximum:   &maximum,
-			minLength: &minLength,
-			maxLength: &maxLength,
-			expected:  false,
+			values:           values,
+			format:           FormatDate,
+			pattern:          pattern,
+			exclusiveMinimum: &exclusiveMinimum,
+			minimum:          &minimum,
+			exclusiveMaximum: &exclusiveMaximum,
+			maximum:          &maximum,
+			minLength:        &minLength,
+			maxLength:        &maxLength,
+			expected:         false,
 		},
 	}
 
 	for k, tc := range cases {
 		validation := &ValidationExpr{
-			Values:    tc.values,
-			Format:    tc.format,
-			Pattern:   tc.pattern,
-			Minimum:   tc.minimum,
-			Maximum:   tc.maximum,
-			MinLength: tc.minLength,
-			MaxLength: tc.maxLength,
+			Values:           tc.values,
+			Format:           tc.format,
+			Pattern:          tc.pattern,
+			ExclusiveMinimum: tc.exclusiveMinimum,
+			Minimum:          tc.minimum,
+			ExclusiveMaximum: tc.exclusiveMaximum,
+			Maximum:          tc.maximum,
+			MinLength:        tc.minLength,
+			MaxLength:        tc.maxLength,
 		}
 		if actual := validation.HasRequiredOnly(); tc.expected != actual {
 			t.Errorf("%s: got %#v, expected %#v", k, actual, tc.expected)