Skip to content

Commit 5f91f47

Browse files
alexluongclaude
andauthored
fix: Go SDK metrics unmarshal error with OptionalNullable[time.Time] (#867)
* test: reproduce Go SDK metrics unmarshal bug with OptionalNullable[time.Time] The Speakeasy-generated unmarshalValue treats OptionalNullable[time.Time] (map[bool]*time.Time) as a regular map with complex value types and tries to unmarshal a datetime string into map[string]json.RawMessage, which fails. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use non-nullable types for metrics time_bucket and granularity The OpenAPI spec used `type: [string, "null"]` for these fields, which causes Speakeasy to generate `OptionalNullable[time.Time]` (map[bool]*T). The Speakeasy unmarshalValue function has a bug where it treats this map type with complex value types (time.Time) as a regular map and tries to unmarshal a datetime string into map[string]json.RawMessage, causing: json: cannot unmarshal string into Go value of type map[string]json.RawMessage Changing to non-nullable `type: string` generates `*time.Time` / `*string` instead, which works correctly. The SDK still handles null values from the API gracefully (null deserializes to nil pointer). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: omit time_bucket and granularity when nil instead of returning null Add omitempty to APIMetricsDataPoint.TimeBucket and APIMetricsMetadata.Granularity so the server omits these fields when nil, matching the non-nullable OpenAPI spec. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0cb30a3 commit 5f91f47

3 files changed

Lines changed: 140 additions & 10 deletions

File tree

docs/apis/openapi.yaml

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2175,11 +2175,9 @@ components:
21752175
type: object
21762176
properties:
21772177
time_bucket:
2178-
type:
2179-
- string
2180-
- "null"
2178+
type: string
21812179
format: date-time
2182-
description: Start of the time bucket. Null when no granularity is specified.
2180+
description: Start of the time bucket. Absent when no granularity is specified.
21832181
example: "2026-03-02T14:00:00Z"
21842182
dimensions:
21852183
type: object
@@ -2201,10 +2199,8 @@ components:
22012199
type: object
22022200
properties:
22032201
granularity:
2204-
type:
2205-
- string
2206-
- "null"
2207-
description: The granularity used for time bucketing, or null if none was specified.
2202+
type: string
2203+
description: The granularity used for time bucketing. Absent when none was specified.
22082204
example: "1h"
22092205
query_time_ms:
22102206
type: integer

internal/apirouter/metrics_handlers.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ var (
6363
// --- API response types ---
6464

6565
type APIMetricsDataPoint struct {
66-
TimeBucket *time.Time `json:"time_bucket"`
66+
TimeBucket *time.Time `json:"time_bucket,omitempty"`
6767
Dimensions map[string]any `json:"dimensions"`
6868
Metrics map[string]any `json:"metrics"`
6969
}
@@ -74,7 +74,7 @@ type APIMetricsResponse struct {
7474
}
7575

7676
type APIMetricsMetadata struct {
77-
Granularity *string `json:"granularity"`
77+
Granularity *string `json:"granularity,omitempty"`
7878
QueryTimeMs int64 `json:"query_time_ms"`
7979
RowCount int `json:"row_count"`
8080
RowLimit int `json:"row_limit"`
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package utils
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
// Minimal reproduction of MetricsDataPoint matching the generated SDK types.
12+
// time_bucket and granularity are now *time.Time / *string (non-nullable in spec).
13+
type testMetricsDataPoint struct {
14+
TimeBucket *time.Time `json:"time_bucket,omitempty"`
15+
Dimensions map[string]string `json:"dimensions,omitempty"`
16+
Metrics map[string]any `json:"metrics,omitempty"`
17+
}
18+
19+
func (m testMetricsDataPoint) MarshalJSON() ([]byte, error) {
20+
return MarshalJSON(m, "", false)
21+
}
22+
23+
func (m *testMetricsDataPoint) UnmarshalJSON(data []byte) error {
24+
return UnmarshalJSON(data, &m, "", false, nil)
25+
}
26+
27+
type testMetricsMetadata struct {
28+
Granularity *string `json:"granularity,omitempty"`
29+
QueryTimeMs *int64 `json:"query_time_ms,omitempty"`
30+
RowCount *int64 `json:"row_count,omitempty"`
31+
RowLimit *int64 `json:"row_limit,omitempty"`
32+
Truncated *bool `json:"truncated,omitempty"`
33+
}
34+
35+
type testMetricsResponse struct {
36+
Data []testMetricsDataPoint `json:"data,omitempty"`
37+
Metadata *testMetricsMetadata `json:"metadata,omitempty"`
38+
}
39+
40+
func TestUnmarshalMetricsResponse_WithTimeBucket(t *testing.T) {
41+
// This is the exact JSON shape returned by the API when granularity is specified.
42+
responseJSON := `{
43+
"data": [
44+
{
45+
"time_bucket": "2026-03-02T14:00:00Z",
46+
"dimensions": {"topic": "user.created"},
47+
"metrics": {"count": 1423}
48+
},
49+
{
50+
"time_bucket": "2026-03-02T15:00:00Z",
51+
"dimensions": {"topic": "user.created"},
52+
"metrics": {"count": 1891}
53+
}
54+
],
55+
"metadata": {
56+
"granularity": "1h",
57+
"query_time_ms": 5,
58+
"row_count": 2,
59+
"row_limit": 1000,
60+
"truncated": false
61+
}
62+
}`
63+
64+
var out testMetricsResponse
65+
err := UnmarshalJSON([]byte(responseJSON), &out, "", true, nil)
66+
require.NoError(t, err, "unmarshalling metrics response with time_bucket should succeed")
67+
68+
// Verify data points
69+
require.Len(t, out.Data, 2)
70+
71+
// First data point
72+
require.NotNil(t, out.Data[0].TimeBucket)
73+
assert.Equal(t, time.Date(2026, 3, 2, 14, 0, 0, 0, time.UTC), *out.Data[0].TimeBucket)
74+
assert.Equal(t, "user.created", out.Data[0].Dimensions["topic"])
75+
76+
// Second data point
77+
require.NotNil(t, out.Data[1].TimeBucket)
78+
assert.Equal(t, time.Date(2026, 3, 2, 15, 0, 0, 0, time.UTC), *out.Data[1].TimeBucket)
79+
}
80+
81+
func TestUnmarshalMetricsResponse_WithoutTimeBucket(t *testing.T) {
82+
// When no granularity is specified, time_bucket is absent.
83+
responseJSON := `{
84+
"data": [
85+
{
86+
"dimensions": {},
87+
"metrics": {"count": 5000}
88+
}
89+
],
90+
"metadata": {
91+
"query_time_ms": 3,
92+
"row_count": 1,
93+
"row_limit": 1000,
94+
"truncated": false
95+
}
96+
}`
97+
98+
var out testMetricsResponse
99+
err := UnmarshalJSON([]byte(responseJSON), &out, "", true, nil)
100+
require.NoError(t, err, "unmarshalling metrics response without time_bucket should succeed")
101+
102+
require.Len(t, out.Data, 1)
103+
assert.Nil(t, out.Data[0].TimeBucket)
104+
assert.Nil(t, out.Metadata.Granularity)
105+
}
106+
107+
func TestUnmarshalMetricsResponse_WithNullTimeBucket(t *testing.T) {
108+
// The API server currently returns "time_bucket": null (no omitempty on server side).
109+
// The SDK should handle this gracefully — null deserializes to nil *time.Time.
110+
responseJSON := `{
111+
"data": [
112+
{
113+
"time_bucket": null,
114+
"dimensions": {},
115+
"metrics": {"count": 5000}
116+
}
117+
],
118+
"metadata": {
119+
"granularity": null,
120+
"query_time_ms": 3,
121+
"row_count": 1,
122+
"row_limit": 1000,
123+
"truncated": false
124+
}
125+
}`
126+
127+
var out testMetricsResponse
128+
err := UnmarshalJSON([]byte(responseJSON), &out, "", true, nil)
129+
require.NoError(t, err, "unmarshalling metrics response with null time_bucket should succeed")
130+
131+
require.Len(t, out.Data, 1)
132+
assert.Nil(t, out.Data[0].TimeBucket)
133+
assert.Nil(t, out.Metadata.Granularity)
134+
}

0 commit comments

Comments
 (0)