diff --git a/internal/xrd/infer.go b/internal/xrd/infer.go index f14b9640..f141ee1b 100644 --- a/internal/xrd/infer.go +++ b/internal/xrd/infer.go @@ -20,12 +20,22 @@ package xrd import ( "fmt" "maps" + "math" extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" ) +const ( + schemaTypeArray = "array" + schemaTypeBoolean = "boolean" + schemaTypeInteger = "integer" + schemaTypeNumber = "number" + schemaTypeObject = "object" + schemaTypeString = "string" +) + // InferProperties infers JSON schema properties from a map of values. func InferProperties(spec map[string]any) (map[string]extv1.JSONSchemaProps, error) { properties := make(map[string]extv1.JSONSchemaProps) @@ -46,10 +56,10 @@ func InferProperties(spec map[string]any) (map[string]extv1.JSONSchemaProps, err func inferArrayProperty(v []any) (extv1.JSONSchemaProps, error) { if len(v) == 0 { return extv1.JSONSchemaProps{ - Type: "array", + Type: schemaTypeArray, Items: &extv1.JSONSchemaPropsOrArray{ Schema: &extv1.JSONSchemaProps{ - Type: "object", + Type: schemaTypeObject, }, }, }, nil @@ -61,7 +71,7 @@ func inferArrayProperty(v []any) (extv1.JSONSchemaProps, error) { } mergedProperties := make(map[string]extv1.JSONSchemaProps) - if firstElemSchema.Type == "object" { + if firstElemSchema.Type == schemaTypeObject { maps.Copy(mergedProperties, firstElemSchema.Properties) } @@ -70,21 +80,31 @@ func inferArrayProperty(v []any) (extv1.JSONSchemaProps, error) { if err != nil { return extv1.JSONSchemaProps{}, err } + + // If an array contains a mix of numbers and integers, use number as the + // array type. + if elemSchema.Type == schemaTypeInteger && firstElemSchema.Type == schemaTypeNumber { + continue + } + if elemSchema.Type == schemaTypeNumber && firstElemSchema.Type == schemaTypeInteger { + firstElemSchema.Type = schemaTypeNumber + } + if elemSchema.Type != firstElemSchema.Type { return extv1.JSONSchemaProps{}, errors.New("mixed types detected in array") } - if elemSchema.Type == "object" { + if elemSchema.Type == schemaTypeObject { maps.Copy(mergedProperties, elemSchema.Properties) } } resultSchema := firstElemSchema - if firstElemSchema.Type == "object" && len(mergedProperties) > 0 { + if firstElemSchema.Type == schemaTypeObject && len(mergedProperties) > 0 { resultSchema.Properties = mergedProperties } return extv1.JSONSchemaProps{ - Type: "array", + Type: schemaTypeArray, Items: &extv1.JSONSchemaPropsOrArray{ Schema: &resultSchema, }, @@ -94,26 +114,45 @@ func inferArrayProperty(v []any) (extv1.JSONSchemaProps, error) { func inferProperty(value any) (extv1.JSONSchemaProps, error) { if value == nil { return extv1.JSONSchemaProps{ - Type: "string", + Type: schemaTypeString, }, nil } switch v := value.(type) { case string: return extv1.JSONSchemaProps{ - Type: "string", + Type: schemaTypeString, }, nil case int, int32, int64: return extv1.JSONSchemaProps{ - Type: "integer", + Type: schemaTypeInteger, }, nil - case float32, float64: + case float32: + // JSON doesn't have integers, so json.Unmarshal treats all numbers as + // floats. Try to detect whehter the number is actually an integer, so + // that we're more likely to infer the user's intent. This heuristic + // isn't perfect since not all integers are representable as floats, but + // it will work for common cases. + t := schemaTypeNumber + if math.Trunc(float64(v)) == float64(v) { + t = schemaTypeInteger + } + + return extv1.JSONSchemaProps{ + Type: t, + }, nil + case float64: + t := schemaTypeNumber + if math.Trunc(v) == v { + t = schemaTypeInteger + } + return extv1.JSONSchemaProps{ - Type: "number", + Type: t, }, nil case bool: return extv1.JSONSchemaProps{ - Type: "boolean", + Type: schemaTypeBoolean, }, nil case map[string]any: inferredProps, err := InferProperties(v) @@ -121,7 +160,7 @@ func inferProperty(value any) (extv1.JSONSchemaProps, error) { return extv1.JSONSchemaProps{}, errors.Wrap(err, "error inferring properties for object") } return extv1.JSONSchemaProps{ - Type: "object", + Type: schemaTypeObject, Properties: inferredProps, }, nil case []any: diff --git a/internal/xrd/infer_test.go b/internal/xrd/infer_test.go index 900b8b06..b4986523 100644 --- a/internal/xrd/infer_test.go +++ b/internal/xrd/infer_test.go @@ -38,25 +38,31 @@ func TestInferProperty(t *testing.T) { "StringType": { input: "hello", want: want{ - output: extv1.JSONSchemaProps{Type: "string"}, + output: extv1.JSONSchemaProps{Type: schemaTypeString}, }, }, "IntegerType": { input: 42, want: want{ - output: extv1.JSONSchemaProps{Type: "integer"}, + output: extv1.JSONSchemaProps{Type: schemaTypeInteger}, }, }, "FloatType": { input: 3.14, want: want{ - output: extv1.JSONSchemaProps{Type: "number"}, + output: extv1.JSONSchemaProps{Type: schemaTypeNumber}, + }, + }, + "IntegerAsFloatType": { + input: float64(1), + want: want{ + output: extv1.JSONSchemaProps{Type: schemaTypeInteger}, }, }, "BooleanType": { input: true, want: want{ - output: extv1.JSONSchemaProps{Type: "boolean"}, + output: extv1.JSONSchemaProps{Type: schemaTypeBoolean}, }, }, "ObjectType": { @@ -65,9 +71,9 @@ func TestInferProperty(t *testing.T) { }, want: want{ output: extv1.JSONSchemaProps{ - Type: "object", + Type: schemaTypeObject, Properties: map[string]extv1.JSONSchemaProps{ - "key": {Type: "string"}, + "key": {Type: schemaTypeString}, }, }, }, @@ -76,9 +82,31 @@ func TestInferProperty(t *testing.T) { input: []any{"one", "two"}, want: want{ output: extv1.JSONSchemaProps{ - Type: "array", + Type: schemaTypeArray, + Items: &extv1.JSONSchemaPropsOrArray{ + Schema: &extv1.JSONSchemaProps{Type: schemaTypeString}, + }, + }, + }, + }, + "ArrayWithMixedNumbersIntegerFirst": { + input: []any{1, float32(3.14)}, + want: want{ + output: extv1.JSONSchemaProps{ + Type: schemaTypeArray, + Items: &extv1.JSONSchemaPropsOrArray{ + Schema: &extv1.JSONSchemaProps{Type: schemaTypeNumber}, + }, + }, + }, + }, + "ArrayWithMixedNumbersFloatFirst": { + input: []any{float32(3.14), 1}, + want: want{ + output: extv1.JSONSchemaProps{ + Type: schemaTypeArray, Items: &extv1.JSONSchemaPropsOrArray{ - Schema: &extv1.JSONSchemaProps{Type: "string"}, + Schema: &extv1.JSONSchemaProps{Type: schemaTypeNumber}, }, }, }, @@ -87,9 +115,9 @@ func TestInferProperty(t *testing.T) { input: []any{}, want: want{ output: extv1.JSONSchemaProps{ - Type: "array", + Type: schemaTypeArray, Items: &extv1.JSONSchemaPropsOrArray{ - Schema: &extv1.JSONSchemaProps{Type: "object"}, + Schema: &extv1.JSONSchemaProps{Type: schemaTypeObject}, }, }, }, @@ -97,7 +125,7 @@ func TestInferProperty(t *testing.T) { "NilValue": { input: nil, want: want{ - output: extv1.JSONSchemaProps{Type: "string"}, + output: extv1.JSONSchemaProps{Type: schemaTypeString}, }, }, "ArrayWithMixedTypes": { @@ -123,20 +151,20 @@ func TestInferProperty(t *testing.T) { }, want: want{ output: extv1.JSONSchemaProps{ - Type: "array", + Type: schemaTypeArray, Items: &extv1.JSONSchemaPropsOrArray{ Schema: &extv1.JSONSchemaProps{ - Type: "object", + Type: schemaTypeObject, Properties: map[string]extv1.JSONSchemaProps{ - "name": {Type: "string"}, - "cidr": {Type: "string"}, + "name": {Type: schemaTypeString}, + "cidr": {Type: schemaTypeString}, "serviceEndpoints": { Type: "array", Items: &extv1.JSONSchemaPropsOrArray{ - Schema: &extv1.JSONSchemaProps{Type: "string"}, + Schema: &extv1.JSONSchemaProps{Type: schemaTypeString}, }, }, - "delegation": {Type: "string"}, + "delegation": {Type: schemaTypeString}, }, }, }, @@ -180,8 +208,8 @@ func TestInferProperties(t *testing.T) { }, want: want{ output: map[string]extv1.JSONSchemaProps{ - "key1": {Type: "string"}, - "key2": {Type: "integer"}, + "key1": {Type: schemaTypeString}, + "key2": {Type: schemaTypeInteger}, }, }, }, @@ -194,9 +222,9 @@ func TestInferProperties(t *testing.T) { want: want{ output: map[string]extv1.JSONSchemaProps{ "nested": { - Type: "object", + Type: schemaTypeObject, Properties: map[string]extv1.JSONSchemaProps{ - "key": {Type: "boolean"}, + "key": {Type: schemaTypeBoolean}, }, }, }, @@ -211,7 +239,7 @@ func TestInferProperties(t *testing.T) { "array": { Type: "array", Items: &extv1.JSONSchemaPropsOrArray{ - Schema: &extv1.JSONSchemaProps{Type: "string"}, + Schema: &extv1.JSONSchemaProps{Type: schemaTypeString}, }, }, },