From bf09dbc8bdaf37fddce1f2114816d764e13d06cb Mon Sep 17 00:00:00 2001 From: Adalberto Sampaio Date: Wed, 29 Jul 2026 16:04:13 -0400 Subject: [PATCH] fix(config): preserve integer precision in Configuration unmarshaling Add custom UnmarshalJSON on Configuration that uses json.Decoder with UseNumber() to prevent large integers (>= 1e6) from becoming float64 and rendering as scientific notation in Go templates (e.g. "2e+06"). --- config/types/configuration.go | 47 +++++++++++++++++++++ config/types/configuration_test.go | 66 ++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/config/types/configuration.go b/config/types/configuration.go index 7fe30032..ad208dde 100644 --- a/config/types/configuration.go +++ b/config/types/configuration.go @@ -1,6 +1,8 @@ package types import ( + "bytes" + "encoding/json" "fmt" "os" "path/filepath" @@ -26,6 +28,51 @@ func (e MissingKeyError) Error() string { return fmt.Sprintf("configuration%s: key %s not found", e.Path, e.Key) } +// UnmarshalJSON overrides the default JSON unmarshaling to preserve integer +// precision. Without it, all numbers become float64 and values >= 1e6 render +// as scientific notation in Go templates (e.g. "2e+06"). +func (c *Configuration) UnmarshalJSON(data []byte) error { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + var raw map[string]any + if err := dec.Decode(&raw); err != nil { + return err + } + if raw == nil { + *c = nil + return nil + } + *c = Configuration(convertJSONNumbers(raw).(map[string]any)) + return nil +} + +// convertJSONNumbers recursively walks a decoded JSON value and converts +// json.Number to int64 (whole numbers) or float64 (fractional). +func convertJSONNumbers(v any) any { + switch val := v.(type) { + case map[string]any: + for k, inner := range val { + val[k] = convertJSONNumbers(inner) + } + return val + case []any: + for i, inner := range val { + val[i] = convertJSONNumbers(inner) + } + return val + case json.Number: + if i, err := val.Int64(); err == nil { + return i + } + if f, err := val.Float64(); err == nil { + return f + } + return val.String() + default: + return val + } +} + func (v Configuration) GetByPath(path string) (any, error) { keys := strings.Split(path, ".") var current any = map[string]any(v) diff --git a/config/types/configuration_test.go b/config/types/configuration_test.go index d914635d..d8fe0013 100644 --- a/config/types/configuration_test.go +++ b/config/types/configuration_test.go @@ -1,10 +1,76 @@ package types import ( + "bytes" + "encoding/json" "path/filepath" "testing" + "text/template" + + "sigs.k8s.io/yaml" ) +func TestConfiguration_UnmarshalJSON_NestedAndArrays(t *testing.T) { + input := `{ + "nested": {"count": 5000000}, + "items": [3000000, 1.5, "text"] + }` + + var cfg Configuration + if err := json.Unmarshal([]byte(input), &cfg); err != nil { + t.Fatalf("UnmarshalJSON failed: %v", err) + } + + nested, ok := cfg["nested"].(map[string]any) + if !ok { + t.Fatalf("nested is %T, want map[string]any", cfg["nested"]) + } + if v := nested["count"]; v != int64(5000000) { + t.Errorf("nested.count = %v (%T), want int64(5000000)", v, v) + } + + items, ok := cfg["items"].([]any) + if !ok { + t.Fatalf("items is %T, want []any", cfg["items"]) + } + if items[0] != int64(3000000) { + t.Errorf("items[0] = %v (%T), want int64(3000000)", items[0], items[0]) + } + if items[1] != float64(1.5) { + t.Errorf("items[1] = %v (%T), want float64(1.5)", items[1], items[1]) + } + if items[2] != "text" { + t.Errorf("items[2] = %v (%T), want \"text\"", items[2], items[2]) + } +} + +func TestConfiguration_UnmarshalJSON_Null(t *testing.T) { + var cfg Configuration + if err := json.Unmarshal([]byte("null"), &cfg); err != nil { + t.Fatalf("UnmarshalJSON failed: %v", err) + } + if cfg != nil { + t.Fatalf("cfg = %#v, want nil", cfg) + } +} + +func TestConfiguration_TemplateRendering_NoScientificNotation(t *testing.T) { + input := `largeInt: 2000000` + var cfg Configuration + if err := yaml.Unmarshal([]byte(input), &cfg); err != nil { + t.Fatalf("yaml.Unmarshal failed: %v", err) + } + + tmpl := template.Must(template.New("t").Parse("{{ .largeInt }}")) + var buf bytes.Buffer + if err := tmpl.Execute(&buf, map[string]any(cfg)); err != nil { + t.Fatalf("template.Execute failed: %v", err) + } + if got := buf.String(); got != "2000000" { + t.Errorf("template rendered %q, want %q", got, "2000000") + } +} + func TestResolveSchemaPath(t *testing.T) { tests := []struct { name string