diff --git a/alpha/declcfg/declcfg.go b/alpha/declcfg/declcfg.go index 8c7dfb015..994d47a0b 100644 --- a/alpha/declcfg/declcfg.go +++ b/alpha/declcfg/declcfg.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "strings" "golang.org/x/text/cases" utilerrors "k8s.io/apimachinery/pkg/util/errors" @@ -104,7 +103,11 @@ func (m Meta) MarshalJSON() ([]byte, error) { func (m *Meta) UnmarshalJSON(blob []byte) error { blobMap := map[string]interface{}{} if err := json.Unmarshal(blob, &blobMap); err != nil { - return err + // TODO: unfortunately, there are libraries between here and the original caller + // that eat our error type and return a generic error, such that we lose the + // ability to errors.As to get this error on the other side. For now, just return + // a string error that includes the pretty printed message. + return errors.New(newJSONUnmarshalError(blob, err).Pretty()) } // TODO: this function ensures we do not break backwards compatibility with @@ -176,47 +179,3 @@ func extractUniqueMetaKeys(blobMap map[string]any, m *Meta) error { } return nil } - -func resolveUnmarshalErr(data []byte, err error) string { - var te *json.UnmarshalTypeError - if errors.As(err, &te) { - return formatUnmarshallErrorString(data, te.Error(), te.Offset) - } - var se *json.SyntaxError - if errors.As(err, &se) { - return formatUnmarshallErrorString(data, se.Error(), se.Offset) - } - return err.Error() -} - -func formatUnmarshallErrorString(data []byte, errmsg string, offset int64) string { - sb := new(strings.Builder) - _, _ = sb.WriteString(fmt.Sprintf("%s at offset %d (indicated by <==)\n ", errmsg, offset)) - // attempt to present the erroneous JSON in indented, human-readable format - // errors result in presenting the original, unformatted output - var pretty bytes.Buffer - err := json.Indent(&pretty, data, "", " ") - if err == nil { - pString := pretty.String() - // calc the prettified string offset which correlates to the original string offset - var pOffset, origOffset int64 - origOffset = 0 - for origOffset = 0; origOffset < offset; { - if pString[pOffset] != '\n' && pString[pOffset] != ' ' { - origOffset++ - } - pOffset++ - } - _, _ = sb.WriteString(pString[:pOffset]) - _, _ = sb.WriteString(" <== ") - _, _ = sb.WriteString(pString[pOffset:]) - } else { - for i := int64(0); i < offset; i++ { - _ = sb.WriteByte(data[i]) - } - _, _ = sb.WriteString(" <== ") - _, _ = sb.Write(data[offset:]) - } - - return sb.String() -} diff --git a/alpha/declcfg/errors.go b/alpha/declcfg/errors.go new file mode 100644 index 000000000..f5ef11523 --- /dev/null +++ b/alpha/declcfg/errors.go @@ -0,0 +1,93 @@ +package declcfg + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "strings" +) + +type jsonUnmarshalError struct { + data []byte + offset int64 + err error +} + +func newJSONUnmarshalError(data []byte, err error) *jsonUnmarshalError { + var te *json.UnmarshalTypeError + if errors.As(err, &te) { + return &jsonUnmarshalError{data: data, offset: te.Offset, err: te} + } + var se *json.SyntaxError + if errors.As(err, &se) { + return &jsonUnmarshalError{data: data, offset: se.Offset, err: se} + } + return &jsonUnmarshalError{data: data, offset: -1, err: err} +} + +func (e *jsonUnmarshalError) Error() string { + return e.err.Error() +} + +func (e *jsonUnmarshalError) Pretty() string { + if len(e.data) == 0 || e.offset < 0 || e.offset > int64(len(e.data)) { + return e.err.Error() + } + + const marker = " <==" + + var sb strings.Builder + _, _ = sb.WriteString(fmt.Sprintf("%s at offset %d (indicated by%s)\n", e.err.Error(), e.offset, marker)) + + prettyBuf := bytes.NewBuffer(make([]byte, 0, len(e.data))) + err := json.Indent(prettyBuf, e.data, "", " ") + + // If there was an error indenting the JSON, just treat the original data as the pretty data. + if err != nil { + prettyBuf = bytes.NewBuffer(e.data) + } + + // If the offset is at the end of the data, just print the pretty data and the marker at the end. + if int(e.offset) == len(e.data) { + _, _ = sb.WriteString(prettyBuf.String()) + _, _ = sb.WriteString(marker) + return sb.String() + } + + // If the offset is within the data, find the corresponding offset in the pretty data. + var ( + pIndex int + pOffset int + ) + pretty := prettyBuf.Bytes() + for dIndex, b := range e.data { + // If we've reached the offset, record it and break out of the loop + if dIndex == int(e.offset) { + pOffset = pIndex + break + } + + // Fast-forward the pretty index until we find the byte in the pretty data + // that matches the byte in the original data. + for pretty[pIndex] != b { + pIndex++ + if pIndex >= len(pretty) { + // Something went wrong. For example, if the pretty data somehow reordered + // the bytes or is missing a byte + return e.err.Error() + } + } + + // We found the byte in the pretty data that matches the byte in the original data, + // so increment the pretty index. + pIndex++ + + } + + _, _ = sb.Write(pretty[:pOffset]) + _, _ = sb.WriteString(fmt.Sprintf("%s ", marker)) + _, _ = sb.Write(pretty[pOffset:]) + + return sb.String() +} diff --git a/alpha/declcfg/errors_test.go b/alpha/declcfg/errors_test.go new file mode 100644 index 000000000..9da8f86b9 --- /dev/null +++ b/alpha/declcfg/errors_test.go @@ -0,0 +1,154 @@ +package declcfg + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestJsonUnmarshalError(t *testing.T) { + type testCase struct { + name string + data []byte + inErr error + expectErrorString string + expectPrettyString string + } + validData := []byte(`{"messages": ["Hello", "world!"]}`) + invalidData := []byte(`{"messages": ["Hello", "world!"]`) + for _, tc := range []testCase{ + { + name: "unknown error", + data: validData, + inErr: errors.New("unknown error"), + expectErrorString: "unknown error", + expectPrettyString: "unknown error", + }, + { + name: "unmarshal type error: no data", + data: nil, + inErr: &json.UnmarshalTypeError{Value: "foo", Type: reflect.TypeOf(""), Offset: 0}, + expectErrorString: `json: cannot unmarshal foo into Go value of type string`, + expectPrettyString: `json: cannot unmarshal foo into Go value of type string`, + }, + { + name: "unmarshal type error: negative offset", + data: validData, + inErr: &json.UnmarshalTypeError{Value: "foo", Type: reflect.TypeOf(""), Offset: -1}, + expectErrorString: `json: cannot unmarshal foo into Go value of type string`, + expectPrettyString: `json: cannot unmarshal foo into Go value of type string`, + }, + { + name: "unmarshal type error: greater than length", + data: validData, + inErr: &json.UnmarshalTypeError{Value: "foo", Type: reflect.TypeOf(""), Offset: int64(len(validData) + 1)}, + expectErrorString: `json: cannot unmarshal foo into Go value of type string`, + expectPrettyString: `json: cannot unmarshal foo into Go value of type string`, + }, + { + name: "unmarshal type error: offset at beginning", + data: validData, + inErr: &json.UnmarshalTypeError{Value: "foo", Type: reflect.TypeOf(""), Offset: 0}, + expectErrorString: `json: cannot unmarshal foo into Go value of type string`, + expectPrettyString: `json: cannot unmarshal foo into Go value of type string at offset 0 (indicated by <==) + <== { + "messages": [ + "Hello", + "world!" + ] +}`, + }, + { + name: "unmarshal type error: offset at 1", + data: validData, + inErr: &json.UnmarshalTypeError{Value: "foo", Type: reflect.TypeOf(""), Offset: 1}, + expectErrorString: `json: cannot unmarshal foo into Go value of type string`, + expectPrettyString: `json: cannot unmarshal foo into Go value of type string at offset 1 (indicated by <==) +{ <== + "messages": [ + "Hello", + "world!" + ] +}`, + }, + { + name: "unmarshal type error: offset at end", + data: validData, + inErr: &json.UnmarshalTypeError{Value: "foo", Type: reflect.TypeOf(""), Offset: int64(len(validData))}, + expectErrorString: `json: cannot unmarshal foo into Go value of type string`, + expectPrettyString: fmt.Sprintf(`json: cannot unmarshal foo into Go value of type string at offset %d (indicated by <==) +{ + "messages": [ + "Hello", + "world!" + ] +} <==`, len(validData)), + }, + { + name: "syntax error: no data", + data: nil, + inErr: json.Unmarshal(invalidData, nil), + expectErrorString: `unexpected end of JSON input`, + expectPrettyString: `unexpected end of JSON input`, + }, + { + name: "syntax error: negative offset", + data: invalidData, + inErr: customOffsetSyntaxError(invalidData, -1), + expectErrorString: `unexpected end of JSON input`, + expectPrettyString: `unexpected end of JSON input`, + }, + { + name: "syntax error: greater than length", + data: invalidData, + inErr: customOffsetSyntaxError(invalidData, int64(len(invalidData)+1)), + expectErrorString: `unexpected end of JSON input`, + expectPrettyString: `unexpected end of JSON input`, + }, + { + name: "syntax error: offset at beginning", + data: invalidData, + inErr: customOffsetSyntaxError(invalidData, 0), + expectErrorString: `unexpected end of JSON input`, + expectPrettyString: `unexpected end of JSON input at offset 0 (indicated by <==) + <== {"messages": ["Hello", "world!"]`, + }, + { + name: "syntax error: offset at 1", + data: invalidData, + inErr: customOffsetSyntaxError(invalidData, 1), + expectErrorString: `unexpected end of JSON input`, + expectPrettyString: `unexpected end of JSON input at offset 1 (indicated by <==) +{ <== "messages": ["Hello", "world!"]`, + }, + { + name: "syntax error: offset at end", + data: invalidData, + inErr: customOffsetSyntaxError(invalidData, int64(len(invalidData))), + expectErrorString: `unexpected end of JSON input`, + expectPrettyString: fmt.Sprintf(`unexpected end of JSON input at offset %d (indicated by <==) +{"messages": ["Hello", "world!"] <==`, len(invalidData)), + }, + } { + t.Run(tc.name, func(t *testing.T) { + actualErr := newJSONUnmarshalError(tc.data, tc.inErr) + assert.Equal(t, tc.expectErrorString, actualErr.Error()) + assert.Equal(t, tc.expectPrettyString, actualErr.Pretty()) + }) + } +} + +// customOffsetSyntaxError returns a json.SyntaxError with the given offset. +// json.SyntaxError does not have a public constructor, so we have to use +// json.Unmarshal to create one and then set the offset manually. +// +// If the data does not cause a syntax error, this function will panic. +func customOffsetSyntaxError(data []byte, offset int64) *json.SyntaxError { + err := json.Unmarshal(data, nil).(*json.SyntaxError) + err.Offset = offset + return err +}