Skip to content

Commit b5e8028

Browse files
authored
fix: support optional backtrace fields and types (#9)
* allow string and int types for backtrace number field * add missing optional backtrace field types * coerce number/column fields to int instead of string
1 parent 929888e commit b5e8028

File tree

2 files changed

+305
-2
lines changed

2 files changed

+305
-2
lines changed

internal/hbapi/types.go

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,39 @@
11
package hbapi
22

3-
import "time"
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"strconv"
7+
"time"
8+
)
9+
10+
// Number is a custom type that can unmarshal from either a string or integer JSON value
11+
// and stores it as an integer
12+
type Number int
13+
14+
// UnmarshalJSON implements json.Unmarshaler interface to handle both string and integer values
15+
func (n *Number) UnmarshalJSON(data []byte) error {
16+
// Try to unmarshal as integer first
17+
var num int
18+
if err := json.Unmarshal(data, &num); err == nil {
19+
*n = Number(num)
20+
return nil
21+
}
22+
23+
// If that fails, try as string and parse to int
24+
var str string
25+
if err := json.Unmarshal(data, &str); err == nil {
26+
// Try to parse the string as an integer
27+
parsed, err := strconv.Atoi(str)
28+
if err != nil {
29+
return fmt.Errorf("Number: cannot parse string %q as integer: %v", str, err)
30+
}
31+
*n = Number(parsed)
32+
return nil
33+
}
34+
35+
return fmt.Errorf("Number: cannot unmarshal value into integer or string")
36+
}
437

538
// User represents a Honeybadger user
639
type User struct {
@@ -95,9 +128,13 @@ type NoticeRequest struct {
95128

96129
// BacktraceEntry represents a single entry in the error backtrace
97130
type BacktraceEntry struct {
98-
Number string `json:"number"`
131+
Number Number `json:"number"`
132+
Column *Number `json:"column,omitempty"`
99133
File string `json:"file"`
100134
Method string `json:"method"`
135+
Class string `json:"class,omitempty"`
136+
Type string `json:"type,omitempty"`
137+
Args []interface{} `json:"args,omitempty"`
101138
Source map[string]interface{} `json:"source,omitempty"`
102139
Context string `json:"context,omitempty"`
103140
}

internal/hbapi/types_test.go

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
package hbapi
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
)
7+
8+
func TestNumber_UnmarshalJSON(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
input string
12+
want int
13+
wantErr bool
14+
}{
15+
{
16+
name: "string value",
17+
input: `"123"`,
18+
want: 123,
19+
wantErr: false,
20+
},
21+
{
22+
name: "integer value",
23+
input: `123`,
24+
want: 123,
25+
wantErr: false,
26+
},
27+
{
28+
name: "zero integer",
29+
input: `0`,
30+
want: 0,
31+
wantErr: false,
32+
},
33+
{
34+
name: "negative integer",
35+
input: `-1`,
36+
want: -1,
37+
wantErr: false,
38+
},
39+
{
40+
name: "string negative integer",
41+
input: `"-42"`,
42+
want: -42,
43+
wantErr: false,
44+
},
45+
{
46+
name: "non-numeric string should fail",
47+
input: `"abc"`,
48+
wantErr: true,
49+
},
50+
{
51+
name: "boolean value should fail",
52+
input: `true`,
53+
wantErr: true,
54+
},
55+
{
56+
name: "object value should fail",
57+
input: `{}`,
58+
wantErr: true,
59+
},
60+
}
61+
62+
for _, tt := range tests {
63+
t.Run(tt.name, func(t *testing.T) {
64+
var n Number
65+
err := json.Unmarshal([]byte(tt.input), &n)
66+
if (err != nil) != tt.wantErr {
67+
t.Errorf("Number.UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr)
68+
return
69+
}
70+
if !tt.wantErr && int(n) != tt.want {
71+
t.Errorf("Number.UnmarshalJSON() = %v, want %v", n, tt.want)
72+
}
73+
})
74+
}
75+
}
76+
77+
func TestBacktraceEntry_UnmarshalJSON(t *testing.T) {
78+
tests := []struct {
79+
name string
80+
input string
81+
want BacktraceEntry
82+
wantErr bool
83+
}{
84+
{
85+
name: "number as string",
86+
input: `{
87+
"number": "42",
88+
"file": "/path/to/file.js",
89+
"method": "someMethod"
90+
}`,
91+
want: BacktraceEntry{
92+
Number: 42,
93+
File: "/path/to/file.js",
94+
Method: "someMethod",
95+
},
96+
wantErr: false,
97+
},
98+
{
99+
name: "number as integer",
100+
input: `{
101+
"number": 42,
102+
"file": "/path/to/file.js",
103+
"method": "someMethod"
104+
}`,
105+
want: BacktraceEntry{
106+
Number: 42,
107+
File: "/path/to/file.js",
108+
Method: "someMethod",
109+
},
110+
wantErr: false,
111+
},
112+
{
113+
name: "complete backtrace entry with source",
114+
input: `{
115+
"number": 10,
116+
"file": "/app/index.js",
117+
"method": "handleError",
118+
"source": {"10": "throw new Error();"},
119+
"context": "app"
120+
}`,
121+
want: BacktraceEntry{
122+
Number: 10,
123+
File: "/app/index.js",
124+
Method: "handleError",
125+
Source: map[string]interface{}{"10": "throw new Error();"},
126+
Context: "app",
127+
},
128+
wantErr: false,
129+
},
130+
{
131+
name: "backtrace entry with all optional fields",
132+
input: `{
133+
"number": "25",
134+
"column": 15,
135+
"file": "/app/models/user.rb",
136+
"method": "authenticate",
137+
"class": "User",
138+
"type": "instance",
139+
"args": ["email@example.com", "password"],
140+
"source": {"25": "user = User.find_by(email: email)"},
141+
"context": "app"
142+
}`,
143+
want: BacktraceEntry{
144+
Number: 25,
145+
Column: numberPtr(15),
146+
File: "/app/models/user.rb",
147+
Method: "authenticate",
148+
Class: "User",
149+
Type: "instance",
150+
Args: []interface{}{"email@example.com", "password"},
151+
Source: map[string]interface{}{"25": "user = User.find_by(email: email)"},
152+
Context: "app",
153+
},
154+
wantErr: false,
155+
},
156+
{
157+
name: "backtrace entry with column as string",
158+
input: `{
159+
"number": 42,
160+
"column": "8",
161+
"file": "/lib/helper.js",
162+
"method": "processData"
163+
}`,
164+
want: BacktraceEntry{
165+
Number: 42,
166+
Column: numberPtr(8),
167+
File: "/lib/helper.js",
168+
Method: "processData",
169+
},
170+
wantErr: false,
171+
},
172+
{
173+
name: "number as string negative",
174+
input: `{
175+
"number": "-10",
176+
"file": "/app/test.rb",
177+
"method": "test"
178+
}`,
179+
want: BacktraceEntry{
180+
Number: -10,
181+
File: "/app/test.rb",
182+
Method: "test",
183+
},
184+
wantErr: false,
185+
},
186+
{
187+
name: "invalid string number",
188+
input: `{
189+
"number": "abc",
190+
"file": "/app/test.rb",
191+
"method": "test"
192+
}`,
193+
wantErr: true,
194+
},
195+
{
196+
name: "number as boolean should fail",
197+
input: `{
198+
"number": true,
199+
"file": "/app/test.rb",
200+
"method": "test"
201+
}`,
202+
wantErr: true,
203+
},
204+
{
205+
name: "column as boolean should fail",
206+
input: `{
207+
"number": 1,
208+
"column": true,
209+
"file": "/app/test.rb",
210+
"method": "test"
211+
}`,
212+
wantErr: true,
213+
},
214+
}
215+
216+
for _, tt := range tests {
217+
t.Run(tt.name, func(t *testing.T) {
218+
var entry BacktraceEntry
219+
err := json.Unmarshal([]byte(tt.input), &entry)
220+
if (err != nil) != tt.wantErr {
221+
t.Errorf("BacktraceEntry.UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr)
222+
return
223+
}
224+
if !tt.wantErr {
225+
if int(entry.Number) != int(tt.want.Number) {
226+
t.Errorf("BacktraceEntry.Number = %v, want %v", entry.Number, tt.want.Number)
227+
}
228+
if entry.File != tt.want.File {
229+
t.Errorf("BacktraceEntry.File = %v, want %v", entry.File, tt.want.File)
230+
}
231+
if entry.Method != tt.want.Method {
232+
t.Errorf("BacktraceEntry.Method = %v, want %v", entry.Method, tt.want.Method)
233+
}
234+
if entry.Context != tt.want.Context {
235+
t.Errorf("BacktraceEntry.Context = %v, want %v", entry.Context, tt.want.Context)
236+
}
237+
// Check Column
238+
if tt.want.Column != nil && entry.Column != nil {
239+
if int(*entry.Column) != int(*tt.want.Column) {
240+
t.Errorf("BacktraceEntry.Column = %v, want %v", *entry.Column, *tt.want.Column)
241+
}
242+
} else if (tt.want.Column == nil) != (entry.Column == nil) {
243+
t.Errorf("BacktraceEntry.Column = %v, want %v", entry.Column, tt.want.Column)
244+
}
245+
// Check Class
246+
if entry.Class != tt.want.Class {
247+
t.Errorf("BacktraceEntry.Class = %v, want %v", entry.Class, tt.want.Class)
248+
}
249+
// Check Type
250+
if entry.Type != tt.want.Type {
251+
t.Errorf("BacktraceEntry.Type = %v, want %v", entry.Type, tt.want.Type)
252+
}
253+
// Check Args length
254+
if len(entry.Args) != len(tt.want.Args) {
255+
t.Errorf("BacktraceEntry.Args length = %v, want %v", len(entry.Args), len(tt.want.Args))
256+
}
257+
}
258+
})
259+
}
260+
}
261+
262+
// Helper function to create Number pointers
263+
func numberPtr(i int) *Number {
264+
n := Number(i)
265+
return &n
266+
}

0 commit comments

Comments
 (0)