Repository: stripe/stripe-go
Pull request #894 (2019-07-16 21:20:05)

Introduce encoding for high precision decimal fields
Introduces a strategy for encoding forthcoming "high precision" decimal fields which will soon be seen in the API. These differ from standards floats in the way that the API cares about their fractional values, and that we'll be encoding them as strings in JSON to try and make as faithfully accurate to the original value as possible.

I reused almost all of OB's prototype branch for this, but moved away from manually specified precision as a form option like `precision=10` and over to a simple tag for `high_precision`. The thesis behind the change is that the float formatting precision of `-1`, which attemps to use as few encoded numbers as is possible to produce a string that's parsable back as the original number, is going to be appropriate for all cases that we're likely to see, and taking another dimension of configuration away is more likely to help us maintain uniform conventions.

If it turns out that we need configurable precision at some point down the line, we can bring back the original `precision=` without much trouble. (I'm even leaving OB's commit in Git history.)

r? @ob-stripe Mind taking a look at this?
cc @stripe/api-libraries 


Patch:
diff --git a/form/form.go b/form/form.go
index d2726e4e..aa321cfb 100644
--- a/form/form.go
+++ b/form/form.go
@@ -53,6 +53,22 @@ type formOptions struct {
 	// should be an empty string. It's used to workaround the fact that an
 	// empty string is a string's zero value and wouldn't normally be encoded.
 	Empty bool
+
+	// HighPrecision indicates that this field should be treated as a high
+	// precision decimal, a decimal whose precision is important to the API and
+	// which we want to encode as accurately as possible.
+	//
+	// All parameters are encoded using form encoding, so this of course
+	// encodes a value to a string, but notably, these high precision fields
+	// are sent back as strings in JSON, even though they might be surface as
+	// floats in this library.
+	//
+	// This isn't a perfect abstraction because floats are not precise in
+	// nature, and we might be better-advised to use a real high-precision data
+	// type like `big.Float`. That said, we suspect that this will be an
+	// adequate solution in the vast majority of cases and has a usability
+	// benefit, so we've gone this route.
+	HighPrecision bool
 }
 
 type structEncoder struct {
@@ -214,7 +230,14 @@ func float32Encoder(values *Values, v reflect.Value, keyParts []string, encodeZe
 	if val == 0.0 && !encodeZero {
 		return
 	}
-	values.Add(FormatKey(keyParts), strconv.FormatFloat(val, 'f', 4, 32))
+	prec := 4
+	if options != nil && options.HighPrecision {
+		// Special value that tells Go to format the float in as few required
+		// digits as necessary for it to be successfully parsable back to the
+		// same original number.
+		prec = -1
+	}
+	values.Add(FormatKey(keyParts), strconv.FormatFloat(val, 'f', prec, 32))
 }
 
 func float64Encoder(values *Values, v reflect.Value, keyParts []string, encodeZero bool, options *formOptions) {
@@ -222,7 +245,14 @@ func float64Encoder(values *Values, v reflect.Value, keyParts []string, encodeZe
 	if val == 0.0 && !encodeZero {
 		return
 	}
-	values.Add(FormatKey(keyParts), strconv.FormatFloat(val, 'f', 4, 64))
+	prec := 4
+	if options != nil && options.HighPrecision {
+		// Special value that tells Go to format the float in as few required
+		// digits as necessary for it to be successfully parsable back to the
+		// same original number.
+		prec = -1
+	}
+	values.Add(FormatKey(keyParts), strconv.FormatFloat(val, 'f', prec, 64))
 }
 
 func getCachedOrBuildStructEncoder(t reflect.Type) *structEncoder {
@@ -382,11 +412,29 @@ func makeStructEncoder(t reflect.Type) *structEncoder {
 		fldTyp := reflectField.Type
 		fldKind := fldTyp.Kind()
 
-		if Strict && options != nil && options.Empty && fldKind != reflect.Bool {
-			panic(fmt.Sprintf(
-				"Cannot specify `empty` for non-boolean field; on: %s/%s",
-				t.Name(), reflectField.Name,
-			))
+		if Strict && options != nil {
+			if options.Empty && fldKind != reflect.Bool {
+				panic(fmt.Sprintf(
+					"Cannot specify `empty` for non-boolean field; on: %s/%s",
+					t.Name(), reflectField.Name,
+				))
+			}
+
+			var k reflect.Kind
+			if fldKind == reflect.Ptr {
+				k = fldTyp.Elem().Kind()
+			} else {
+				k = fldKind
+			}
+
+			fldIsFloat := k == reflect.Float32 || k == reflect.Float64
+
+			if options.HighPrecision && !fldIsFloat {
+				panic(fmt.Sprintf(
+					"Cannot specify `high_precision` for non-float field; on: %s/%s (%s)",
+					t.Name(), reflectField.Name, fldTyp,
+				))
+			}
 		}
 
 		se.fields = append(se.fields, &field{
@@ -455,6 +503,12 @@ func parseTag(tag string) (string, *formOptions) {
 			}
 			options.Empty = true
 
+		case "high_precision":
+			if options == nil {
+				options = &formOptions{}
+			}
+			options.HighPrecision = true
+
 		default:
 			if Strict {
 				part := parts[i]


Test patch:
diff --git a/form/form_test.go b/form/form_test.go
index 34b64408..5e152d97 100644
--- a/form/form_test.go
+++ b/form/form_test.go
@@ -33,9 +33,15 @@ type testStruct struct {
 	Float32    float32  `form:"float32"`
 	Float32Ptr *float32 `form:"float32_ptr"`
 
+	Float32Precise    float32  `form:"float32_precise,high_precision"`
+	Float32PrecisePtr *float32 `form:"float32_precise_ptr,high_precision"`
+
 	Float64    float64  `form:"float64"`
 	Float64Ptr *float64 `form:"float64_ptr"`
 
+	Float64Precise    float64  `form:"float64_precise,high_precision"`
+	Float64PrecisePtr *float64 `form:"float64_precise_ptr,high_precision"`
+
 	Ignored string `form:"-"`
 
 	Int      int    `form:"int"`
@@ -127,9 +133,13 @@ func TestAppendTo(t *testing.T) {
 	var float32Val float32 = 1.2345
 	var float32Val0 float32
 
+	var float32PreciseVal float32 = 0.123456789012
+
 	var float64Val = 1.2345
 	var float64Val0 = 0.0
 
+	var float64PreciseVal = 0.123456789012345678901234
+
 	var intVal = 123
 	var intVal0 = 0
 	var int8Val int8 = 123
@@ -140,7 +150,6 @@ func TestAppendTo(t *testing.T) {
 	var int32Val0 int32
 	var int64Val int64 = 123
 	var int64Val0 int64
-
 	var sliceVal = []string{"1", "2", "3"}
 	var sliceVal0 = []string{}
 
@@ -192,11 +201,29 @@ func TestAppendTo(t *testing.T) {
 		{"float32_ptr", &testStruct{Float32Ptr: &float32Val0}, stringPtr("0.0000")},
 		{"float32_ptr", &testStruct{}, nil},
 
+		// Tests float32 with high precision
+		{"float32_precise", &testStruct{Float32Precise: float32PreciseVal}, stringPtr("0.12345679")},
+		{"float32_precise_ptr", &testStruct{Float32PrecisePtr: &float32PreciseVal}, stringPtr("0.12345679")},
+		{"float32_precise_ptr", &testStruct{Float32PrecisePtr: &float32Val0}, stringPtr("0")},
+		{"float32_precise_ptr", &testStruct{}, nil},
+
+		// The 32-bit test value we're using it already beyond the length of
+		// 32-bit precision. The 64-bit value (which starts with the same
+		// decimals) is well beyond it, and therefore encodes to the same
+		// value.
+		{"float32_precise", &testStruct{Float32Precise: float32(float64PreciseVal)}, stringPtr("0.12345679")},
+
 		{"float64", &testStruct{Float64: float64Val}, stringPtr("1.2345")},
 		{"float64_ptr", &testStruct{Float64Ptr: &float64Val}, stringPtr("1.2345")},
 		{"float64_ptr", &testStruct{Float64Ptr: &float64Val0}, stringPtr("0.0000")},
 		{"float64_ptr", &testStruct{}, nil},
 
+		// Tests float64 with high precision
+		{"float64_precise", &testStruct{Float64Precise: float64PreciseVal}, stringPtr("0.12345678901234568")},
+		{"float64_precise_ptr", &testStruct{Float64PrecisePtr: &float64PreciseVal}, stringPtr("0.12345678901234568")},
+		{"float64_precise_ptr", &testStruct{Float64PrecisePtr: &float64Val0}, stringPtr("0")},
+		{"float64_precise_ptr", &testStruct{}, nil},
+
 		{"int", &testStruct{Int: intVal}, stringPtr("123")},
 		{"int_ptr", &testStruct{IntPtr: &intVal}, stringPtr("123")},
 		{"int_ptr", &testStruct{IntPtr: &intVal0}, stringPtr("0")},