From 24976cd19292e48f14774cf5a1ad03c36b23e84b Mon Sep 17 00:00:00 2001 From: Adam Wolfe Gordon Date: Mon, 15 Jun 2026 10:50:46 -0600 Subject: [PATCH 1/2] xrd: Opportunistically infer integer types when generating from an XR When generating an XRD from an example XR, we have to infer the type of each field from the example input. We do this by unmarshalling into a `map[string]any` and then observing the actual type of each value. Since the JSON specification has only a single numerical type, which is floating point, `json.Unmarshal` treats all numbers as floats. This means a field like `replicas: 3` in an example XR produces a `number` field in the OpenAPI spec when an `integer` field would be more appropriate. Detect integers by comparing the truncation of the value to the original value. This is an imperfect heuristic since not all integers are representable as floats, but it will work for common cases (small integers) and produce less surprising behavior for users. Signed-off-by: Adam Wolfe Gordon --- internal/xrd/infer.go | 24 ++++++++++++++++++++++-- internal/xrd/infer_test.go | 6 ++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/internal/xrd/infer.go b/internal/xrd/infer.go index f14b9640..62f29aea 100644 --- a/internal/xrd/infer.go +++ b/internal/xrd/infer.go @@ -20,6 +20,7 @@ package xrd import ( "fmt" "maps" + "math" extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" @@ -107,9 +108,28 @@ func inferProperty(value any) (extv1.JSONSchemaProps, error) { return extv1.JSONSchemaProps{ Type: "integer", }, 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 := "number" + if math.Trunc(float64(v)) == float64(v) { + t = "integer" + } + + return extv1.JSONSchemaProps{ + Type: t, + }, nil + case float64: + t := "number" + if math.Trunc(v) == v { + t = "integer" + } + return extv1.JSONSchemaProps{ - Type: "number", + Type: t, }, nil case bool: return extv1.JSONSchemaProps{ diff --git a/internal/xrd/infer_test.go b/internal/xrd/infer_test.go index 900b8b06..f6312646 100644 --- a/internal/xrd/infer_test.go +++ b/internal/xrd/infer_test.go @@ -53,6 +53,12 @@ func TestInferProperty(t *testing.T) { output: extv1.JSONSchemaProps{Type: "number"}, }, }, + "IntegerAsFloatType": { + input: float64(1), + want: want{ + output: extv1.JSONSchemaProps{Type: "integer"}, + }, + }, "BooleanType": { input: true, want: want{ From 25a5e5a027f81cfbe56d265ffbc5e8f5dbafd67a Mon Sep 17 00:00:00 2001 From: Adam Wolfe Gordon Date: Mon, 15 Jun 2026 13:06:40 -0600 Subject: [PATCH 2/2] xrd: Allow for mixed number types in arrays when inferring types If an array in an example XR contains a mix of integers and floats, use "number" as the array's element type rather than returning an error. Signed-off-by: Adam Wolfe Gordon --- internal/xrd/infer.go | 49 ++++++++++++++++++--------- internal/xrd/infer_test.go | 68 +++++++++++++++++++++++++------------- 2 files changed, 79 insertions(+), 38 deletions(-) diff --git a/internal/xrd/infer.go b/internal/xrd/infer.go index 62f29aea..f141ee1b 100644 --- a/internal/xrd/infer.go +++ b/internal/xrd/infer.go @@ -27,6 +27,15 @@ import ( "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) @@ -47,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 @@ -62,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) } @@ -71,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, }, @@ -95,18 +114,18 @@ 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: // JSON doesn't have integers, so json.Unmarshal treats all numbers as @@ -114,18 +133,18 @@ func inferProperty(value any) (extv1.JSONSchemaProps, error) { // 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 := "number" + t := schemaTypeNumber if math.Trunc(float64(v)) == float64(v) { - t = "integer" + t = schemaTypeInteger } return extv1.JSONSchemaProps{ Type: t, }, nil case float64: - t := "number" + t := schemaTypeNumber if math.Trunc(v) == v { - t = "integer" + t = schemaTypeInteger } return extv1.JSONSchemaProps{ @@ -133,7 +152,7 @@ func inferProperty(value any) (extv1.JSONSchemaProps, error) { }, nil case bool: return extv1.JSONSchemaProps{ - Type: "boolean", + Type: schemaTypeBoolean, }, nil case map[string]any: inferredProps, err := InferProperties(v) @@ -141,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 f6312646..b4986523 100644 --- a/internal/xrd/infer_test.go +++ b/internal/xrd/infer_test.go @@ -38,31 +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: "integer"}, + output: extv1.JSONSchemaProps{Type: schemaTypeInteger}, }, }, "BooleanType": { input: true, want: want{ - output: extv1.JSONSchemaProps{Type: "boolean"}, + output: extv1.JSONSchemaProps{Type: schemaTypeBoolean}, }, }, "ObjectType": { @@ -71,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}, }, }, }, @@ -82,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: "string"}, + 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: schemaTypeNumber}, }, }, }, @@ -93,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}, }, }, }, @@ -103,7 +125,7 @@ func TestInferProperty(t *testing.T) { "NilValue": { input: nil, want: want{ - output: extv1.JSONSchemaProps{Type: "string"}, + output: extv1.JSONSchemaProps{Type: schemaTypeString}, }, }, "ArrayWithMixedTypes": { @@ -129,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}, }, }, }, @@ -186,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}, }, }, }, @@ -200,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}, }, }, }, @@ -217,7 +239,7 @@ func TestInferProperties(t *testing.T) { "array": { Type: "array", Items: &extv1.JSONSchemaPropsOrArray{ - Schema: &extv1.JSONSchemaProps{Type: "string"}, + Schema: &extv1.JSONSchemaProps{Type: schemaTypeString}, }, }, },