diff --git a/openapi2conv/openapi2_conv.go b/openapi2conv/openapi2_conv.go index 37c70eca6..3c3509ac9 100644 --- a/openapi2conv/openapi2_conv.go +++ b/openapi2conv/openapi2_conv.go @@ -261,25 +261,25 @@ func ToV3Parameter(components *openapi3.Components, parameter *openapi2.Paramete required = []string{parameter.Name} } schemaRef := &openapi3.SchemaRef{Value: &openapi3.Schema{ - Description: parameter.Description, - Type: typ, - Extensions: stripNonExtensions(parameter.Extensions), - Format: format, - Enum: parameter.Enum, - Min: parameter.Minimum, - Max: parameter.Maximum, - ExclusiveMin: parameter.ExclusiveMin, - ExclusiveMax: parameter.ExclusiveMax, - MinLength: parameter.MinLength, - MaxLength: parameter.MaxLength, - Default: parameter.Default, - MinItems: parameter.MinItems, - MaxItems: parameter.MaxItems, - Pattern: parameter.Pattern, - AllowEmptyValue: parameter.AllowEmptyValue, - UniqueItems: parameter.UniqueItems, - MultipleOf: parameter.MultipleOf, - Required: required, + Description: parameter.Description, + Type: typ, + Extensions: stripNonExtensions(parameter.Extensions), + Format: format, + Enum: parameter.Enum, + Min: parameter.Minimum, + Max: parameter.Maximum, + ExclusiveMinBool: ¶meter.ExclusiveMin, + ExclusiveMaxBool: ¶meter.ExclusiveMax, + MinLength: parameter.MinLength, + MaxLength: parameter.MaxLength, + Default: parameter.Default, + MinItems: parameter.MinItems, + MaxItems: parameter.MaxItems, + Pattern: parameter.Pattern, + AllowEmptyValue: parameter.AllowEmptyValue, + UniqueItems: parameter.UniqueItems, + MultipleOf: parameter.MultipleOf, + Required: required, }} if parameter.Items != nil { schemaRef.Value.Items = ToV3SchemaRef(parameter.Items) @@ -494,8 +494,8 @@ func ToV3SchemaRef(schema *openapi2.SchemaRef) *openapi3.SchemaRef { Example: schema.Value.Example, ExternalDocs: schema.Value.ExternalDocs, UniqueItems: schema.Value.UniqueItems, - ExclusiveMin: schema.Value.ExclusiveMin, - ExclusiveMax: schema.Value.ExclusiveMax, + ExclusiveMinBool: &schema.Value.ExclusiveMin, + ExclusiveMaxBool: &schema.Value.ExclusiveMax, ReadOnly: schema.Value.ReadOnly, WriteOnly: schema.Value.WriteOnly, AllowEmptyValue: schema.Value.AllowEmptyValue, @@ -867,19 +867,17 @@ func FromV3SchemaRef(schema *openapi3.SchemaRef, components *openapi3.Components break } } - return nil, &openapi2.Parameter{ - In: "formData", - Name: originalName, - Description: schema.Value.Description, - Type: paramType, - Enum: schema.Value.Enum, - Minimum: schema.Value.Min, - Maximum: schema.Value.Max, - ExclusiveMin: schema.Value.ExclusiveMin, - ExclusiveMax: schema.Value.ExclusiveMax, - MinLength: schema.Value.MinLength, - MaxLength: schema.Value.MaxLength, - Default: schema.Value.Default, + param := &openapi2.Parameter{ + In: "formData", + Name: originalName, + Description: schema.Value.Description, + Type: paramType, + Enum: schema.Value.Enum, + Minimum: schema.Value.Min, + Maximum: schema.Value.Max, + MinLength: schema.Value.MinLength, + MaxLength: schema.Value.MaxLength, + Default: schema.Value.Default, // Items: schema.Value.Items, MinItems: schema.Value.MinItems, MaxItems: schema.Value.MaxItems, @@ -889,6 +887,13 @@ func FromV3SchemaRef(schema *openapi3.SchemaRef, components *openapi3.Components Extensions: stripNonExtensions(schema.Value.Extensions), Required: required, } + if schema.Value.ExclusiveMinBool != nil { + param.ExclusiveMin = *schema.Value.ExclusiveMinBool + } + if schema.Value.ExclusiveMaxBool != nil { + param.ExclusiveMax = *schema.Value.ExclusiveMaxBool + } + return nil, param } } @@ -903,8 +908,6 @@ func FromV3SchemaRef(schema *openapi3.SchemaRef, components *openapi3.Components Example: schema.Value.Example, ExternalDocs: schema.Value.ExternalDocs, UniqueItems: schema.Value.UniqueItems, - ExclusiveMin: schema.Value.ExclusiveMin, - ExclusiveMax: schema.Value.ExclusiveMax, ReadOnly: schema.Value.ReadOnly, WriteOnly: schema.Value.WriteOnly, AllowEmptyValue: schema.Value.AllowEmptyValue, @@ -926,6 +929,13 @@ func FromV3SchemaRef(schema *openapi3.SchemaRef, components *openapi3.Components AdditionalProperties: schema.Value.AdditionalProperties, } + if schema.Value.ExclusiveMinBool != nil { + v2Schema.ExclusiveMin = *schema.Value.ExclusiveMinBool + } + if schema.Value.ExclusiveMaxBool != nil { + v2Schema.ExclusiveMax = *schema.Value.ExclusiveMaxBool + } + if v := schema.Value.Items; v != nil { v2Schema.Items, _ = FromV3SchemaRef(v, components) } @@ -1031,23 +1041,21 @@ func FromV3RequestBodyFormData(mediaType *openapi3.MediaType) openapi2.Parameter v2Items, _ = FromV3SchemaRef(val.Items, nil) } parameter := &openapi2.Parameter{ - Name: propName, - Description: val.Description, - Type: typ, - In: "formData", - Extensions: stripNonExtensions(val.Extensions), - Enum: val.Enum, - ExclusiveMin: val.ExclusiveMin, - ExclusiveMax: val.ExclusiveMax, - MinLength: val.MinLength, - MaxLength: val.MaxLength, - Default: val.Default, - Items: v2Items, - MinItems: val.MinItems, - MaxItems: val.MaxItems, - Maximum: val.Max, - Minimum: val.Min, - Pattern: val.Pattern, + Name: propName, + Description: val.Description, + Type: typ, + In: "formData", + Extensions: stripNonExtensions(val.Extensions), + Enum: val.Enum, + MinLength: val.MinLength, + MaxLength: val.MaxLength, + Default: val.Default, + Items: v2Items, + MinItems: val.MinItems, + MaxItems: val.MaxItems, + Maximum: val.Max, + Minimum: val.Min, + Pattern: val.Pattern, // CollectionFormat: val.CollectionFormat, // Format: val.Format, AllowEmptyValue: val.AllowEmptyValue, @@ -1055,6 +1063,12 @@ func FromV3RequestBodyFormData(mediaType *openapi3.MediaType) openapi2.Parameter UniqueItems: val.UniqueItems, MultipleOf: val.MultipleOf, } + if val.ExclusiveMinBool != nil { + parameter.ExclusiveMin = *val.ExclusiveMinBool + } + if val.ExclusiveMaxBool != nil { + parameter.ExclusiveMax = *val.ExclusiveMaxBool + } parameters = append(parameters, parameter) } return parameters diff --git a/openapi3/schema.go b/openapi3/schema.go index 92411b6f4..55a37e3cf 100644 --- a/openapi3/schema.go +++ b/openapi3/schema.go @@ -100,8 +100,8 @@ type Schema struct { // Array-related, here for struct compactness UniqueItems bool `json:"uniqueItems,omitempty" yaml:"uniqueItems,omitempty"` // Number-related, here for struct compactness - ExclusiveMin bool `json:"exclusiveMinimum,omitempty" yaml:"exclusiveMinimum,omitempty"` - ExclusiveMax bool `json:"exclusiveMaximum,omitempty" yaml:"exclusiveMaximum,omitempty"` + ExclusiveMinBool *bool `json:"-" yaml:"-"` + ExclusiveMaxBool *bool `json:"-" yaml:"-"` // Properties Nullable bool `json:"nullable,omitempty" yaml:"nullable,omitempty"` ReadOnly bool `json:"readOnly,omitempty" yaml:"readOnly,omitempty"` @@ -110,6 +110,10 @@ type Schema struct { Deprecated bool `json:"deprecated,omitempty" yaml:"deprecated,omitempty"` XML *XML `json:"xml,omitempty" yaml:"xml,omitempty"` + // OAS 3.1 exclusiveMinimum and exclusiveMaximum + ExclusiveMin *float64 `json:"-" yaml:"-"` + ExclusiveMax *float64 `json:"-" yaml:"-"` + // Number Min *float64 `json:"minimum,omitempty" yaml:"minimum,omitempty"` Max *float64 `json:"maximum,omitempty" yaml:"maximum,omitempty"` @@ -408,6 +412,11 @@ func (schema Schema) MarshalYAML() (any, error) { m[k] = v } + err := schema.marshalDynamicValues(m) + if err != nil { + return nil, err + } + if x := schema.OneOf; len(x) != 0 { m["oneOf"] = x } @@ -449,13 +458,6 @@ func (schema Schema) MarshalYAML() (any, error) { if x := schema.UniqueItems; x { m["uniqueItems"] = x } - // Number-related - if x := schema.ExclusiveMin; x { - m["exclusiveMinimum"] = x - } - if x := schema.ExclusiveMax; x { - m["exclusiveMaximum"] = x - } // Properties if x := schema.Nullable; x { m["nullable"] = x @@ -567,6 +569,28 @@ func (schema Schema) MarshalYAML() (any, error) { return m, nil } +func (schema *Schema) marshalDynamicValues(m map[string]any) error { + // Handle exclusiveMinimum - OAS 3.1 numeric takes precedence + if schema.ExclusiveMin != nil { + // OAS 3.1 style: numeric value + m["exclusiveMinimum"] = *schema.ExclusiveMin + } else if schema.ExclusiveMinBool != nil && *schema.ExclusiveMinBool { + // OAS 3.0 style: boolean (only if true and numeric not set) + m["exclusiveMinimum"] = true + } + + // Handle exclusiveMaximum - OAS 3.1 numeric takes precedence + if schema.ExclusiveMax != nil { + // OAS 3.1 style: numeric value + m["exclusiveMaximum"] = *schema.ExclusiveMax + } else if schema.ExclusiveMaxBool != nil && *schema.ExclusiveMaxBool { + // OAS 3.0 style: boolean (only if true and numeric not set) + m["exclusiveMaximum"] = true + } + + return nil +} + // UnmarshalJSON sets Schema to a copy of data. func (schema *Schema) UnmarshalJSON(data []byte) error { type SchemaBis Schema @@ -576,6 +600,10 @@ func (schema *Schema) UnmarshalJSON(data []byte) error { } _ = json.Unmarshal(data, &x.Extensions) + if err := (*Schema)(&x).unmarshalDynamicValues(x.Extensions); err != nil { + return err + } + delete(x.Extensions, originKey) delete(x.Extensions, "oneOf") delete(x.Extensions, "anyOf") @@ -654,6 +682,34 @@ func (schema *Schema) UnmarshalJSON(data []byte) error { return nil } +func (schema *Schema) unmarshalDynamicValues(jsonMap map[string]any) error { + // Handle exclusiveMinimum + if val, ok := jsonMap["exclusiveMinimum"]; ok { + switch v := val.(type) { + case bool: + // OAS 3.0 style: boolean + schema.ExclusiveMinBool = &v + case float64: + // OAS 3.1 style: numeric value + schema.ExclusiveMin = &v + } + } + + // Handle exclusiveMaximum + if val, ok := jsonMap["exclusiveMaximum"]; ok { + switch v := val.(type) { + case bool: + // OAS 3.0 style: boolean + schema.ExclusiveMaxBool = &v + case float64: + // OAS 3.1 style: numeric value + schema.ExclusiveMax = &v + } + } + + return nil +} + // JSONLookup implements https://pkg.go.dev/github.com/go-openapi/jsonpointer#JSONPointable func (schema Schema) JSONLookup(token string) (any, error) { switch token { @@ -706,9 +762,15 @@ func (schema Schema) JSONLookup(token string) (any, error) { case "uniqueItems": return schema.UniqueItems, nil case "exclusiveMin": - return schema.ExclusiveMin, nil + if schema.ExclusiveMin != nil { + return schema.ExclusiveMin, nil + } + return schema.ExclusiveMinBool, nil case "exclusiveMax": - return schema.ExclusiveMax, nil + if schema.ExclusiveMax != nil { + return schema.ExclusiveMax, nil + } + return schema.ExclusiveMaxBool, nil case "nullable": return schema.Nullable, nil case "readOnly": @@ -877,12 +939,22 @@ func (schema *Schema) WithMax(value float64) *Schema { } func (schema *Schema) WithExclusiveMin(value bool) *Schema { - schema.ExclusiveMin = value + schema.ExclusiveMinBool = &value return schema } func (schema *Schema) WithExclusiveMax(value bool) *Schema { - schema.ExclusiveMax = value + schema.ExclusiveMaxBool = &value + return schema +} + +func (schema *Schema) WithExclusiveMinNumber(value float64) *Schema { + schema.ExclusiveMin = &value + return schema +} + +func (schema *Schema) WithExclusiveMaxNumber(value float64) *Schema { + schema.ExclusiveMax = &value return schema } @@ -1037,8 +1109,10 @@ func (schema *Schema) PermitsNull() bool { // IsEmpty tells whether schema is equivalent to the empty schema `{}`. func (schema *Schema) IsEmpty() bool { - if schema.Type != nil || schema.Format != "" || len(schema.Enum) != 0 || - schema.UniqueItems || schema.ExclusiveMin || schema.ExclusiveMax || + if schema.Type != nil || schema.Format != "" || len(schema.Enum) != 0 || schema.UniqueItems || + (schema.ExclusiveMinBool != nil && *schema.ExclusiveMinBool) || + (schema.ExclusiveMaxBool != nil && *schema.ExclusiveMaxBool) || + schema.ExclusiveMin != nil || schema.ExclusiveMax != nil || schema.Nullable || schema.ReadOnly || schema.WriteOnly || schema.AllowEmptyValue || schema.Min != nil || schema.Max != nil || schema.MultipleOf != nil || schema.MinLength != 0 || schema.MaxLength != nil || schema.Pattern != "" || @@ -1766,7 +1840,7 @@ func (schema *Schema) visitJSONNumber(settings *schemaValidationSettings, value } // "exclusiveMinimum" - if v := schema.ExclusiveMin; v && !(*schema.Min < value) { + if v := schema.ExclusiveMinBool; (v != nil && *v) && !(*schema.Min < value) { if settings.failfast { return errSchema } @@ -1784,7 +1858,7 @@ func (schema *Schema) visitJSONNumber(settings *schemaValidationSettings, value } // "exclusiveMaximum" - if v := schema.ExclusiveMax; v && !(*schema.Max > value) { + if v := schema.ExclusiveMaxBool; (v != nil && *v) && !(*schema.Max > value) { if settings.failfast { return errSchema } @@ -1801,6 +1875,42 @@ func (schema *Schema) visitJSONNumber(settings *schemaValidationSettings, value me = append(me, err) } + // "exclusiveMinimum" (OAS 3.1 numeric form) + if v := schema.ExclusiveMin; v != nil && !(*v < value) { + if settings.failfast { + return errSchema + } + err := &SchemaError{ + Value: value, + Schema: schema, + SchemaField: "exclusiveMinimum", + Reason: fmt.Sprintf("number must be more than %g", *v), + customizeMessageError: settings.customizeMessageError, + } + if !settings.multiError { + return err + } + me = append(me, err) + } + + // "exclusiveMaximum" (OAS 3.1 numeric form) + if v := schema.ExclusiveMax; v != nil && !(*v > value) { + if settings.failfast { + return errSchema + } + err := &SchemaError{ + Value: value, + Schema: schema, + SchemaField: "exclusiveMaximum", + Reason: fmt.Sprintf("number must be less than %g", *v), + customizeMessageError: settings.customizeMessageError, + } + if !settings.multiError { + return err + } + me = append(me, err) + } + // "minimum" if v := schema.Min; v != nil && !(*v <= value) { if settings.failfast { diff --git a/openapi3/schema_exclusive_bounds_test.go b/openapi3/schema_exclusive_bounds_test.go new file mode 100644 index 000000000..2db1c55e7 --- /dev/null +++ b/openapi3/schema_exclusive_bounds_test.go @@ -0,0 +1,683 @@ +package openapi3 + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestExclusiveMinMax_Unmarshal(t *testing.T) { + t.Run("boolean exclusiveMinimum true", func(t *testing.T) { + data := `{"type": "number", "minimum": 0, "exclusiveMinimum": true}` + var schema Schema + err := json.Unmarshal([]byte(data), &schema) + require.NoError(t, err) + require.NotNil(t, schema.ExclusiveMinBool) + require.True(t, *schema.ExclusiveMinBool) + require.Nil(t, schema.ExclusiveMin) + }) + + t.Run("boolean exclusiveMinimum false", func(t *testing.T) { + data := `{"type": "number", "minimum": 0, "exclusiveMinimum": false}` + var schema Schema + err := json.Unmarshal([]byte(data), &schema) + require.NoError(t, err) + require.NotNil(t, schema.ExclusiveMinBool) + require.False(t, *schema.ExclusiveMinBool) + require.Nil(t, schema.ExclusiveMin) + }) + + t.Run("boolean exclusiveMaximum true", func(t *testing.T) { + data := `{"type": "number", "maximum": 100, "exclusiveMaximum": true}` + var schema Schema + err := json.Unmarshal([]byte(data), &schema) + require.NoError(t, err) + require.NotNil(t, schema.ExclusiveMaxBool) + require.True(t, *schema.ExclusiveMaxBool) + require.Nil(t, schema.ExclusiveMax) + }) + + t.Run("boolean exclusiveMaximum false", func(t *testing.T) { + data := `{"type": "number", "maximum": 100, "exclusiveMaximum": false}` + var schema Schema + err := json.Unmarshal([]byte(data), &schema) + require.NoError(t, err) + require.NotNil(t, schema.ExclusiveMaxBool) + require.False(t, *schema.ExclusiveMaxBool) + require.Nil(t, schema.ExclusiveMax) + }) + + t.Run("numeric exclusiveMinimum zero", func(t *testing.T) { + data := `{"type": "number", "exclusiveMinimum": 0.0}` + var schema Schema + err := json.Unmarshal([]byte(data), &schema) + require.NoError(t, err) + require.NotNil(t, schema.ExclusiveMin) + require.Equal(t, 0.0, *schema.ExclusiveMin) + require.Nil(t, schema.ExclusiveMinBool) + }) + + t.Run("numeric exclusiveMinimum positive", func(t *testing.T) { + data := `{"type": "number", "exclusiveMinimum": 100.5}` + var schema Schema + err := json.Unmarshal([]byte(data), &schema) + require.NoError(t, err) + require.NotNil(t, schema.ExclusiveMin) + require.Equal(t, 100.5, *schema.ExclusiveMin) + require.Nil(t, schema.ExclusiveMinBool) + }) + + t.Run("numeric exclusiveMinimum negative", func(t *testing.T) { + data := `{"type": "number", "exclusiveMinimum": -50.0}` + var schema Schema + err := json.Unmarshal([]byte(data), &schema) + require.NoError(t, err) + require.NotNil(t, schema.ExclusiveMin) + require.Equal(t, -50.0, *schema.ExclusiveMin) + require.Nil(t, schema.ExclusiveMinBool) + }) + + t.Run("numeric exclusiveMaximum zero", func(t *testing.T) { + data := `{"type": "number", "exclusiveMaximum": 0.0}` + var schema Schema + err := json.Unmarshal([]byte(data), &schema) + require.NoError(t, err) + require.NotNil(t, schema.ExclusiveMax) + require.Equal(t, 0.0, *schema.ExclusiveMax) + require.Nil(t, schema.ExclusiveMaxBool) + }) + + t.Run("numeric exclusiveMaximum positive", func(t *testing.T) { + data := `{"type": "number", "exclusiveMaximum": 100.5}` + var schema Schema + err := json.Unmarshal([]byte(data), &schema) + require.NoError(t, err) + require.NotNil(t, schema.ExclusiveMax) + require.Equal(t, 100.5, *schema.ExclusiveMax) + require.Nil(t, schema.ExclusiveMaxBool) + }) + + t.Run("numeric exclusiveMaximum negative", func(t *testing.T) { + data := `{"type": "number", "exclusiveMaximum": -50.0}` + var schema Schema + err := json.Unmarshal([]byte(data), &schema) + require.NoError(t, err) + require.NotNil(t, schema.ExclusiveMax) + require.Equal(t, -50.0, *schema.ExclusiveMax) + require.Nil(t, schema.ExclusiveMaxBool) + }) + + t.Run("complete OAS 3.0 schema with minimum and boolean", func(t *testing.T) { + data := `{ + "type": "number", + "minimum": 0, + "maximum": 100, + "exclusiveMinimum": true, + "exclusiveMaximum": true + }` + var schema Schema + err := json.Unmarshal([]byte(data), &schema) + require.NoError(t, err) + require.NotNil(t, schema.Min) + require.Equal(t, 0.0, *schema.Min) + require.NotNil(t, schema.Max) + require.Equal(t, 100.0, *schema.Max) + require.NotNil(t, schema.ExclusiveMinBool) + require.True(t, *schema.ExclusiveMinBool) + require.NotNil(t, schema.ExclusiveMaxBool) + require.True(t, *schema.ExclusiveMaxBool) + require.Nil(t, schema.ExclusiveMin) + require.Nil(t, schema.ExclusiveMax) + }) + + t.Run("complete OAS 3.1 schema with numeric only", func(t *testing.T) { + data := `{ + "type": "number", + "exclusiveMinimum": 0, + "exclusiveMaximum": 100 + }` + var schema Schema + err := json.Unmarshal([]byte(data), &schema) + require.NoError(t, err) + require.NotNil(t, schema.ExclusiveMin) + require.Equal(t, 0.0, *schema.ExclusiveMin) + require.NotNil(t, schema.ExclusiveMax) + require.Equal(t, 100.0, *schema.ExclusiveMax) + require.Nil(t, schema.Min) + require.Nil(t, schema.Max) + require.Nil(t, schema.ExclusiveMinBool) + require.Nil(t, schema.ExclusiveMaxBool) + }) +} + +func TestExclusiveMinMax_Marshal(t *testing.T) { + t.Run("boolean true marshals as true", func(t *testing.T) { + trueVal := true + schema := &Schema{ + Type: &Types{"number"}, + Min: Ptr(0.0), + ExclusiveMinBool: &trueVal, + } + data, err := json.Marshal(schema) + require.NoError(t, err) + + var result map[string]any + err = json.Unmarshal(data, &result) + require.NoError(t, err) + require.Equal(t, true, result["exclusiveMinimum"]) + }) + + t.Run("boolean false does not marshal", func(t *testing.T) { + falseVal := false + schema := &Schema{ + Type: &Types{"number"}, + Min: Ptr(0.0), + ExclusiveMinBool: &falseVal, + } + data, err := json.Marshal(schema) + require.NoError(t, err) + + var result map[string]any + err = json.Unmarshal(data, &result) + require.NoError(t, err) + _, exists := result["exclusiveMinimum"] + require.False(t, exists, "false boolean should not be marshaled") + }) + + t.Run("numeric value marshals correctly", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"number"}, + ExclusiveMin: Ptr(5.5), + } + data, err := json.Marshal(schema) + require.NoError(t, err) + + var result map[string]any + err = json.Unmarshal(data, &result) + require.NoError(t, err) + require.Equal(t, 5.5, result["exclusiveMinimum"]) + }) + + t.Run("numeric takes precedence over boolean", func(t *testing.T) { + trueVal := true + schema := &Schema{ + Type: &Types{"number"}, + Min: Ptr(0.0), + ExclusiveMinBool: &trueVal, + ExclusiveMin: Ptr(10.0), + } + data, err := json.Marshal(schema) + require.NoError(t, err) + + var result map[string]any + err = json.Unmarshal(data, &result) + require.NoError(t, err) + require.Equal(t, 10.0, result["exclusiveMinimum"], "numeric should take precedence") + }) + + t.Run("round-trip OAS 3.0 preserves boolean", func(t *testing.T) { + original := `{"type": "number", "minimum": 0, "exclusiveMinimum": true}` + var schema Schema + err := json.Unmarshal([]byte(original), &schema) + require.NoError(t, err) + + marshaled, err := json.Marshal(&schema) + require.NoError(t, err) + + var schema2 Schema + err = json.Unmarshal(marshaled, &schema2) + require.NoError(t, err) + + require.NotNil(t, schema2.ExclusiveMinBool) + require.True(t, *schema2.ExclusiveMinBool) + require.Nil(t, schema2.ExclusiveMin) + }) + + t.Run("round-trip OAS 3.1 preserves numeric", func(t *testing.T) { + original := `{"type": "number", "exclusiveMinimum": 5.5}` + var schema Schema + err := json.Unmarshal([]byte(original), &schema) + require.NoError(t, err) + + marshaled, err := json.Marshal(&schema) + require.NoError(t, err) + + var schema2 Schema + err = json.Unmarshal(marshaled, &schema2) + require.NoError(t, err) + + require.NotNil(t, schema2.ExclusiveMin) + require.Equal(t, 5.5, *schema2.ExclusiveMin) + require.Nil(t, schema2.ExclusiveMinBool) + }) +} + +func TestExclusiveMinMax_BuiltinValidation(t *testing.T) { + // Boolean form validation - exclusiveMinimum + t.Run("boolean exclusiveMinimum - value above minimum passes", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"number"}, + Min: Ptr(0.0), + ExclusiveMinBool: Ptr(true), + } + err := schema.VisitJSON(0.1) + require.NoError(t, err) + }) + + t.Run("boolean exclusiveMinimum - value at minimum fails", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"number"}, + Min: Ptr(0.0), + ExclusiveMinBool: Ptr(true), + } + err := schema.VisitJSON(0.0) + require.Error(t, err) + require.Contains(t, err.Error(), "exclusiveMinimum") + }) + + t.Run("boolean exclusiveMinimum - value below minimum fails", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"number"}, + Min: Ptr(0.0), + ExclusiveMinBool: Ptr(true), + } + err := schema.VisitJSON(-0.1) + require.Error(t, err) + }) + + // Boolean form validation - exclusiveMaximum + t.Run("boolean exclusiveMaximum - value below maximum passes", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"number"}, + Max: Ptr(100.0), + ExclusiveMaxBool: Ptr(true), + } + err := schema.VisitJSON(99.9) + require.NoError(t, err) + }) + + t.Run("boolean exclusiveMaximum - value at maximum fails", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"number"}, + Max: Ptr(100.0), + ExclusiveMaxBool: Ptr(true), + } + err := schema.VisitJSON(100.0) + require.Error(t, err) + require.Contains(t, err.Error(), "exclusiveMaximum") + }) + + t.Run("boolean exclusiveMaximum - value above maximum fails", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"number"}, + Max: Ptr(100.0), + ExclusiveMaxBool: Ptr(true), + } + err := schema.VisitJSON(100.1) + require.Error(t, err) + }) + + // Numeric form validation - exclusiveMinimum + t.Run("numeric exclusiveMin - value above passes", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"number"}, + ExclusiveMin: Ptr(0.0), + } + err := schema.VisitJSON(0.1) + require.NoError(t, err) + }) + + t.Run("numeric exclusiveMin - value at boundary fails", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"number"}, + ExclusiveMin: Ptr(0.0), + } + err := schema.VisitJSON(0.0) + require.Error(t, err) + require.Contains(t, err.Error(), "exclusiveMinimum") + }) + + t.Run("numeric exclusiveMin - value below fails", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"number"}, + ExclusiveMin: Ptr(0.0), + } + err := schema.VisitJSON(-0.1) + require.Error(t, err) + }) + + // Numeric form validation - exclusiveMaximum + t.Run("numeric exclusiveMax - value below passes", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"number"}, + ExclusiveMax: Ptr(100.0), + } + err := schema.VisitJSON(99.9) + require.NoError(t, err) + }) + + t.Run("numeric exclusiveMax - value at boundary fails", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"number"}, + ExclusiveMax: Ptr(100.0), + } + err := schema.VisitJSON(100.0) + require.Error(t, err) + require.Contains(t, err.Error(), "exclusiveMaximum") + }) + + t.Run("numeric exclusiveMax - value above fails", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"number"}, + ExclusiveMax: Ptr(100.0), + } + err := schema.VisitJSON(100.1) + require.Error(t, err) + }) + + // Edge cases + t.Run("integer type respects numeric exclusive bounds", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"integer"}, + ExclusiveMin: Ptr(0.0), + ExclusiveMax: Ptr(10.0), + } + err := schema.VisitJSON(5) + require.NoError(t, err) + + err = schema.VisitJSON(0) + require.Error(t, err) + + err = schema.VisitJSON(10) + require.Error(t, err) + }) + + t.Run("floating point precision near boundary", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"number"}, + ExclusiveMin: Ptr(0.0), + } + // Slightly above zero should pass + err := schema.VisitJSON(0.000001) + require.NoError(t, err) + + // Exactly zero should fail + err = schema.VisitJSON(0.0) + require.Error(t, err) + }) +} + +func TestExclusiveMinMax_JSONSchema2020(t *testing.T) { + t.Run("OAS 3.0 boolean transforms correctly", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"number"}, + Min: Ptr(0.0), + ExclusiveMinBool: Ptr(true), + } + + err := schema.VisitJSON(0.1, EnableJSONSchema2020()) + require.NoError(t, err) + + err = schema.VisitJSON(0.0, EnableJSONSchema2020()) + require.Error(t, err) + }) + + t.Run("OAS 3.1 numeric passes through", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"number"}, + ExclusiveMin: Ptr(0.0), + } + + err := schema.VisitJSON(0.1, EnableJSONSchema2020()) + require.NoError(t, err) + + err = schema.VisitJSON(0.0, EnableJSONSchema2020()) + require.Error(t, err) + }) + + // Mirror validation tests with JSON Schema 2020-12 + t.Run("numeric exclusiveMin validation with JSONSchema2020", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"number"}, + ExclusiveMin: Ptr(5.0), + } + + err := schema.VisitJSON(6.0, EnableJSONSchema2020()) + require.NoError(t, err) + + err = schema.VisitJSON(5.0, EnableJSONSchema2020()) + require.Error(t, err) + + err = schema.VisitJSON(4.0, EnableJSONSchema2020()) + require.Error(t, err) + }) + + t.Run("numeric exclusiveMax validation with JSONSchema2020", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"number"}, + ExclusiveMax: Ptr(100.0), + } + + err := schema.VisitJSON(99.0, EnableJSONSchema2020()) + require.NoError(t, err) + + err = schema.VisitJSON(100.0, EnableJSONSchema2020()) + require.Error(t, err) + + err = schema.VisitJSON(101.0, EnableJSONSchema2020()) + require.Error(t, err) + }) +} + +func TestExclusiveMinMax_Builders(t *testing.T) { + t.Run("WithExclusiveMin sets boolean", func(t *testing.T) { + schema := NewFloat64Schema().WithExclusiveMin(true) + require.NotNil(t, schema.ExclusiveMinBool) + require.True(t, *schema.ExclusiveMinBool) + require.Nil(t, schema.ExclusiveMin) + }) + + t.Run("WithExclusiveMax sets boolean", func(t *testing.T) { + schema := NewFloat64Schema().WithExclusiveMax(true) + require.NotNil(t, schema.ExclusiveMaxBool) + require.True(t, *schema.ExclusiveMaxBool) + require.Nil(t, schema.ExclusiveMax) + }) + + t.Run("WithExclusiveMinNumber sets numeric", func(t *testing.T) { + schema := NewFloat64Schema().WithExclusiveMinNumber(5.5) + require.NotNil(t, schema.ExclusiveMin) + require.Equal(t, 5.5, *schema.ExclusiveMin) + require.Nil(t, schema.ExclusiveMinBool) + }) + + t.Run("WithExclusiveMaxNumber sets numeric", func(t *testing.T) { + schema := NewFloat64Schema().WithExclusiveMaxNumber(100.5) + require.NotNil(t, schema.ExclusiveMax) + require.Equal(t, 100.5, *schema.ExclusiveMax) + require.Nil(t, schema.ExclusiveMaxBool) + }) + + t.Run("chaining builders works", func(t *testing.T) { + schema := NewFloat64Schema(). + WithExclusiveMinNumber(0.0). + WithExclusiveMaxNumber(100.0) + + require.NotNil(t, schema.ExclusiveMin) + require.Equal(t, 0.0, *schema.ExclusiveMin) + require.NotNil(t, schema.ExclusiveMax) + require.Equal(t, 100.0, *schema.ExclusiveMax) + }) + + t.Run("numeric overwrites boolean in marshaling", func(t *testing.T) { + schema := NewFloat64Schema(). + WithMin(0.0). + WithExclusiveMin(true). + WithExclusiveMinNumber(5.0) + + data, err := json.Marshal(schema) + require.NoError(t, err) + + var result map[string]any + err = json.Unmarshal(data, &result) + require.NoError(t, err) + require.Equal(t, 5.0, result["exclusiveMinimum"]) + }) +} + +func TestExclusiveMinMax_Integration(t *testing.T) { + t.Run("Schema.IsEmpty handles exclusive bounds", func(t *testing.T) { + emptySchema := &Schema{} + require.True(t, emptySchema.IsEmpty()) + + schemaWithBool := &Schema{ExclusiveMinBool: Ptr(true)} + require.False(t, schemaWithBool.IsEmpty()) + + schemaWithNumeric := &Schema{ExclusiveMin: Ptr(0.0)} + require.False(t, schemaWithNumeric.IsEmpty()) + }) + + t.Run("Schema.Validate passes for well-formed schemas", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"number"}, + ExclusiveMin: Ptr(0.0), + ExclusiveMax: Ptr(100.0), + } + err := schema.Validate(context.Background()) + require.NoError(t, err) + }) + + t.Run("complete validation workflow OAS 3.0", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"number"}, + Min: Ptr(0.0), + Max: Ptr(100.0), + ExclusiveMinBool: Ptr(true), + ExclusiveMaxBool: Ptr(true), + } + + // Validate schema structure + err := schema.Validate(context.Background()) + require.NoError(t, err) + + // Validate data against schema + err = schema.VisitJSON(50.0) + require.NoError(t, err) + + err = schema.VisitJSON(0.0) + require.Error(t, err) + + err = schema.VisitJSON(100.0) + require.Error(t, err) + }) + + t.Run("complete validation workflow OAS 3.1", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"number"}, + ExclusiveMin: Ptr(0.0), + ExclusiveMax: Ptr(100.0), + } + + // Validate schema structure + err := schema.Validate(context.Background()) + require.NoError(t, err) + + // Validate data against schema + err = schema.VisitJSON(50.0) + require.NoError(t, err) + + err = schema.VisitJSON(0.0) + require.Error(t, err) + + err = schema.VisitJSON(100.0) + require.Error(t, err) + }) +} + +func TestExclusiveMinMax_ErrorMessages(t *testing.T) { + t.Run("exclusive minimum error message is clear", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"number"}, + ExclusiveMin: Ptr(0.0), + } + err := schema.VisitJSON(0.0) + require.Error(t, err) + + var schemaError *SchemaError + require.ErrorAs(t, err, &schemaError) + require.NotZero(t, schemaError.Reason) + require.Contains(t, schemaError.Reason, "more than") + }) + + t.Run("exclusive maximum error message is clear", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"number"}, + ExclusiveMax: Ptr(100.0), + } + err := schema.VisitJSON(100.0) + require.Error(t, err) + + var schemaError *SchemaError + require.ErrorAs(t, err, &schemaError) + require.NotZero(t, schemaError.Reason) + require.Contains(t, schemaError.Reason, "less than") + }) + + t.Run("error message doesn't leak sensitive data", func(t *testing.T) { + schema := &Schema{ + Type: &Types{"number"}, + ExclusiveMin: Ptr(50.0), + } + sensitiveValue := 42.0 + err := schema.VisitJSON(sensitiveValue) + require.Error(t, err) + + var schemaError *SchemaError + require.ErrorAs(t, err, &schemaError) + // Should not contain the actual value in the reason string + // The value is stored in the error, but not in the reason + require.NotZero(t, schemaError.Reason) + }) +} + +func TestExclusiveMinMax_JSONLookup(t *testing.T) { + t.Run("returns numeric when both set", func(t *testing.T) { + schema := Schema{ + ExclusiveMin: Ptr(10.0), + ExclusiveMinBool: Ptr(true), + } + val, err := schema.JSONLookup("exclusiveMin") + require.NoError(t, err) + require.Equal(t, Ptr(10.0), val) + }) + + t.Run("returns boolean when only boolean set", func(t *testing.T) { + schema := Schema{ + ExclusiveMinBool: Ptr(true), + } + val, err := schema.JSONLookup("exclusiveMin") + require.NoError(t, err) + require.Equal(t, Ptr(true), val) + }) + + t.Run("returns numeric when only numeric set", func(t *testing.T) { + schema := Schema{ + ExclusiveMin: Ptr(5.5), + } + val, err := schema.JSONLookup("exclusiveMin") + require.NoError(t, err) + require.Equal(t, Ptr(5.5), val) + }) + + t.Run("returns numeric for exclusiveMax when both set", func(t *testing.T) { + schema := Schema{ + ExclusiveMax: Ptr(100.0), + ExclusiveMaxBool: Ptr(true), + } + val, err := schema.JSONLookup("exclusiveMax") + require.NoError(t, err) + require.Equal(t, Ptr(100.0), val) + }) +} diff --git a/openapi3/schema_jsonschema_validator_test.go b/openapi3/schema_jsonschema_validator_test.go index 7382fedac..759f63326 100644 --- a/openapi3/schema_jsonschema_validator_test.go +++ b/openapi3/schema_jsonschema_validator_test.go @@ -136,9 +136,9 @@ func TestJSONSchema2020Validator_ExclusiveMinMax(t *testing.T) { t.Run("exclusive minimum as boolean (OpenAPI 3.0 style)", func(t *testing.T) { min := 0.0 schema := &Schema{ - Type: &Types{"number"}, - Min: &min, - ExclusiveMin: true, + Type: &Types{"number"}, + Min: &min, + ExclusiveMinBool: Ptr(true), } err := schema.VisitJSON(0.1, EnableJSONSchema2020()) @@ -151,9 +151,9 @@ func TestJSONSchema2020Validator_ExclusiveMinMax(t *testing.T) { t.Run("exclusive maximum as boolean (OpenAPI 3.0 style)", func(t *testing.T) { max := 100.0 schema := &Schema{ - Type: &Types{"number"}, - Max: &max, - ExclusiveMax: true, + Type: &Types{"number"}, + Max: &max, + ExclusiveMaxBool: Ptr(true), } err := schema.VisitJSON(99.9, EnableJSONSchema2020()) diff --git a/openapi3/testdata/exclusivebounds-oas31.yaml b/openapi3/testdata/exclusivebounds-oas31.yaml new file mode 100644 index 000000000..4fc6138db --- /dev/null +++ b/openapi3/testdata/exclusivebounds-oas31.yaml @@ -0,0 +1,127 @@ +openapi: 3.1.0 +info: + title: Exclusive Bounds Example (OAS 3.1) + version: 1.0.0 + description: | + Example demonstrating OAS 3.1 numeric exclusiveMinimum and exclusiveMaximum. + In OAS 3.1, these are numeric values (not booleans like in OAS 3.0). + +paths: + /products/{productId}: + get: + summary: Get product by ID + parameters: + - name: productId + in: path + required: true + schema: + type: integer + exclusiveMinimum: 0 + exclusiveMaximum: 10000 + description: Product ID must be greater than 0 and less than 10000 + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + + /temperature: + post: + summary: Record temperature reading + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TemperatureReading' + responses: + '201': + description: Temperature recorded + + /percentage: + post: + summary: Submit percentage value + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + value: + type: number + exclusiveMinimum: 0 + exclusiveMaximum: 100 + description: Percentage must be in range (0, 100) - exclusive on both ends + responses: + '200': + description: Success + +components: + schemas: + Product: + type: object + required: + - id + - name + - price + - rating + properties: + id: + type: integer + exclusiveMinimum: 0 + description: Product ID (greater than 0) + name: + type: string + price: + type: number + exclusiveMinimum: 0 + description: Price must be greater than 0 + rating: + type: number + exclusiveMinimum: 0 + exclusiveMaximum: 5 + description: Rating between 0 and 5 (exclusive) + discount: + type: number + exclusiveMinimum: 0 + exclusiveMaximum: 1 + description: Discount as decimal (0, 1) - e.g., 0.25 for 25% off + + TemperatureReading: + type: object + required: + - celsius + - timestamp + properties: + celsius: + type: number + exclusiveMinimum: -273.15 + description: Temperature in Celsius (must be above absolute zero) + fahrenheit: + type: number + exclusiveMinimum: -459.67 + description: Temperature in Fahrenheit (must be above absolute zero) + timestamp: + type: string + format: date-time + + ScientificMeasurement: + type: object + properties: + value: + type: number + exclusiveMinimum: 0 + description: Measurement value (must be positive) + errorMargin: + type: number + exclusiveMinimum: 0 + exclusiveMaximum: 1 + description: Error margin as proportion (0, 1) + confidence: + type: number + exclusiveMinimum: 0 + exclusiveMaximum: 100 + description: Confidence level as percentage