Skip to content

Commit 19afda7

Browse files
authored
event: add JSON marshal/unmarshal to avoid field shadowing (#588)
Implement custom JSON encoding/decoding for Event to define a format that keeps legacy flat fields while adding a nested “response” object, preventing anonymous-embedding collisions (e.g., Event.ID vs Response.ID).
1 parent 3b8999b commit 19afda7

File tree

2 files changed

+368
-0
lines changed

2 files changed

+368
-0
lines changed

event/event.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ package event
1212

1313
import (
1414
"context"
15+
"encoding/json"
1516
"errors"
1617
"strings"
1718
"time"
@@ -280,3 +281,64 @@ func EmitEventWithTimeout(ctx context.Context, ch chan<- *Event,
280281
}
281282
return nil
282283
}
284+
285+
// MarshalJSON implements json.Marshaler and produces a format that
286+
// preserves legacy flattened fields while also embedding minimal
287+
// response metadata (ID/timestamp) under the dedicated "response" key.
288+
func (e Event) MarshalJSON() ([]byte, error) {
289+
payload := jsonEvent{
290+
eventNoMethods: (*eventNoMethods)(&e),
291+
}
292+
if e.Response != nil {
293+
payload.Response = &responseMeta{
294+
ID: e.Response.ID,
295+
Timestamp: e.Response.Timestamp,
296+
}
297+
}
298+
return json.Marshal(payload)
299+
}
300+
301+
// UnmarshalJSON implements json.Unmarshaler by accepting both legacy flattened
302+
// payloads and the new nested-response representation, preferring the nested
303+
// response when present.
304+
func (e *Event) UnmarshalJSON(data []byte) error {
305+
// First parse the flat structure.
306+
var flat eventNoMethods
307+
if err := json.Unmarshal(data, &flat); err != nil {
308+
return err
309+
}
310+
*e = Event(flat)
311+
// Then try to read nested metadata.
312+
var nested struct {
313+
Response *responseMeta `json:"response,omitempty"`
314+
}
315+
// Tolerate nested part failure, it does not affect the overall failure, preserve the flat fields.
316+
if err := json.Unmarshal(data, &nested); err != nil {
317+
log.Warnf("unmarshal response: %v", err)
318+
return nil
319+
}
320+
if nested.Response != nil {
321+
if e.Response == nil {
322+
e.Response = &model.Response{}
323+
}
324+
e.Response.ID = nested.Response.ID
325+
e.Response.Timestamp = nested.Response.Timestamp
326+
}
327+
return nil
328+
}
329+
330+
// eventNoMethods is the alias of Event for avoiding recursive calls of custom MarshalJSON/UnmarshalJSON.
331+
type eventNoMethods Event
332+
333+
// responseMeta is the minimal response metadata for JSON nested.
334+
type responseMeta struct {
335+
ID string `json:"id,omitempty"`
336+
Timestamp time.Time `json:"timestamp,omitempty"`
337+
}
338+
339+
// jsonEvent is the final JSON structure to be output/read,
340+
// including flat event fields and nested response metadata.
341+
type jsonEvent struct {
342+
*eventNoMethods
343+
Response *responseMeta `json:"response,omitempty"`
344+
}

event/event_test.go

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,3 +427,309 @@ func TestWithTag(t *testing.T) {
427427
e := New("inv", "author", WithTag("alpha"), WithTag("beta"))
428428
require.Equal(t, "alpha"+TagDelimiter+"beta", e.Tag)
429429
}
430+
431+
// Test that MarshalJSON outputs a payload that preserves the top-level event
432+
// fields and also includes a nested "response" object carrying Response-only
433+
// identifiers like response.id.
434+
func TestEventMarshalJSON_IncludesNestedResponse(t *testing.T) {
435+
e := &Event{
436+
Response: &model.Response{
437+
ID: "resp-1",
438+
Object: model.ObjectTypeChatCompletion,
439+
Done: true,
440+
Choices: []model.Choice{{Index: 0, Message: model.NewAssistantMessage("hi")}},
441+
Timestamp: time.Now(),
442+
},
443+
ID: "evt-1",
444+
InvocationID: "inv-1",
445+
Author: "assistant",
446+
Timestamp: time.Now(),
447+
}
448+
449+
data, err := json.Marshal(e)
450+
require.NoError(t, err)
451+
452+
// Decode to a raw map for inspection.
453+
var raw map[string]json.RawMessage
454+
require.NoError(t, json.Unmarshal(data, &raw))
455+
456+
// Top-level id should be the event ID.
457+
var topID string
458+
require.NoError(t, json.Unmarshal(raw["id"], &topID))
459+
require.Equal(t, "evt-1", topID)
460+
461+
// Top-level object should remain available for legacy (flattened) readers.
462+
var topObject string
463+
require.NoError(t, json.Unmarshal(raw["object"], &topObject))
464+
require.Equal(t, string(model.ObjectTypeChatCompletion), topObject)
465+
466+
// Nested response must exist and preserve response.id.
467+
nested, ok := raw["response"]
468+
require.True(t, ok, "missing nested response field")
469+
470+
var rsp model.Response
471+
require.NoError(t, json.Unmarshal(nested, &rsp))
472+
require.Equal(t, "resp-1", rsp.ID)
473+
require.Equal(t, "", rsp.Object)
474+
}
475+
476+
// Test that UnmarshalJSON prefers nested response over flattened fields when both
477+
// are present in the input JSON.
478+
func TestEventUnmarshalJSON_PrefersNestedResponse(t *testing.T) {
479+
input := `{
480+
"id": "evt-2",
481+
"object": "chat.completion",
482+
"done": true,
483+
"response": {
484+
"id": "resp-2",
485+
"object": "chat.completion",
486+
"done": true
487+
}
488+
}`
489+
490+
var e Event
491+
require.NoError(t, json.Unmarshal([]byte(input), &e))
492+
require.Equal(t, "evt-2", e.ID)
493+
require.NotNil(t, e.Response)
494+
require.Equal(t, "resp-2", e.Response.ID)
495+
require.Equal(t, model.ObjectTypeChatCompletion, e.Response.Object)
496+
require.True(t, e.Response.Done)
497+
}
498+
499+
// Test that legacy flattened JSON without nested response decodes successfully and
500+
// populates Response fields except the conflicting response.id.
501+
func TestEventUnmarshalJSON_LegacyFlatOnly(t *testing.T) {
502+
// Simulate older payload where response fields live on the top-level due to embedding,
503+
// thus there is no nested "response" and no way to carry response.id.
504+
input := `{
505+
"id": "evt-3",
506+
"object": "chat.completion",
507+
"done": true,
508+
"choices": [{"index":0, "message": {"role":"assistant", "content":"ok"}}]
509+
}`
510+
511+
var e Event
512+
require.NoError(t, json.Unmarshal([]byte(input), &e))
513+
require.Equal(t, "evt-3", e.ID)
514+
require.NotNil(t, e.Response)
515+
require.Equal(t, "", e.Response.ID) // No response.id in legacy flat payload.
516+
require.Equal(t, model.ObjectTypeChatCompletion, e.Response.Object)
517+
require.True(t, e.Response.Done)
518+
require.Len(t, e.Response.Choices, 1)
519+
require.Equal(t, 0, e.Response.Choices[0].Index)
520+
require.Equal(t, model.RoleAssistant, e.Response.Choices[0].Message.Role)
521+
require.Equal(t, "ok", e.Response.Choices[0].Message.Content)
522+
}
523+
524+
// Test marshalJSON on a nil *Event should return an error due to
525+
// attempting to unmarshal a JSON null into the payload map.
526+
func TestEventMarshalJSON_NilReceiver_Error(t *testing.T) {
527+
var e *Event
528+
data, err := json.Marshal(e)
529+
require.NoError(t, err)
530+
require.Equal(t, "null", string(data))
531+
}
532+
533+
// Test unmarshalJSON on a nil pointer should return an error.
534+
func TestEventUnmarshalJSON_NilPointer(t *testing.T) {
535+
var e *Event
536+
err := json.Unmarshal([]byte("null"), e)
537+
require.Error(t, err)
538+
}
539+
540+
// Test unmarshalJSON on a null value should return an error.
541+
func TestEventUnmarshalJSON_NullValue(t *testing.T) {
542+
var e Event
543+
err := json.Unmarshal([]byte("null"), &e)
544+
require.NoError(t, err)
545+
require.Equal(t, Event{}, e)
546+
}
547+
548+
// Test unmarshalJSON should return error on invalid JSON input.
549+
func TestEventUnmarshalJSON_InvalidJSON_Error(t *testing.T) {
550+
var e Event
551+
err := json.Unmarshal([]byte("{"), &e)
552+
require.Error(t, err)
553+
}
554+
555+
// Test unmarshalJSON should return error when decoding into struct with wrong JSON type.
556+
func TestEventUnmarshalJSON_WrongType_Error(t *testing.T) {
557+
var e Event
558+
err := json.Unmarshal([]byte(`"not-an-object"`), &e)
559+
require.Error(t, err)
560+
}
561+
562+
// Test unmarshalJSON should return error when nested response exists but is malformed.
563+
func TestEventUnmarshalJSON_BadNestedResponse(t *testing.T) {
564+
input := `{
565+
"id": "evt-bad",
566+
"object": "chat.completion",
567+
"response": 123
568+
}`
569+
var e Event
570+
err := json.Unmarshal([]byte(input), &e)
571+
require.NoError(t, err)
572+
}
573+
574+
// Test marshalJSON should return error when timestamp overflow.
575+
func TestEventMarshalJSON_TimestampOverflow(t *testing.T) {
576+
t.Run("event with timestamp overflow", func(t *testing.T) {
577+
e := &Event{
578+
Timestamp: time.Unix(1<<60-1, 0),
579+
}
580+
_, err := json.Marshal(e)
581+
require.Error(t, err)
582+
})
583+
t.Run("response with timestamp overflow", func(t *testing.T) {
584+
e := &Event{
585+
Response: &model.Response{
586+
Timestamp: time.Unix(1<<60-1, 0),
587+
},
588+
}
589+
_, err := json.Marshal(e)
590+
require.Error(t, err)
591+
})
592+
}
593+
594+
// Test unmarshalJSON should return error when timestamp overflow.
595+
func TestEventUnMarshalJSON_TimestampOverflow(t *testing.T) {
596+
t.Run("event with timestamp overflow", func(t *testing.T) {
597+
var e Event
598+
err := json.Unmarshal([]byte(`{"timestamp": "12025-01-01T00:00:00Z"}`), &e)
599+
require.Error(t, err)
600+
})
601+
t.Run("response with timestamp overflow", func(t *testing.T) {
602+
var e Event
603+
err := json.Unmarshal([]byte(`{"response": {"timestamp": "12025-01-01T00:00:00Z"}}`), &e)
604+
require.NoError(t, err)
605+
})
606+
}
607+
608+
func TestEventMarshalJSON(t *testing.T) {
609+
t.Run("without struct", func(t *testing.T) {
610+
e := Event{ID: "id1", Response: &model.Response{ID: "id2"}}
611+
data, err := json.Marshal(e)
612+
require.NoError(t, err)
613+
var dst Event
614+
require.NoError(t, json.Unmarshal(data, &dst))
615+
require.Equal(t, "id1", dst.ID)
616+
require.Equal(t, "id2", dst.Response.ID)
617+
})
618+
t.Run("with pointer", func(t *testing.T) {
619+
e := &Event{ID: "id1", Response: &model.Response{ID: "id2"}}
620+
data, err := json.Marshal(e)
621+
require.NoError(t, err)
622+
var dst Event
623+
require.NoError(t, json.Unmarshal(data, &dst))
624+
require.Equal(t, "id1", dst.ID)
625+
require.Equal(t, "id2", dst.Response.ID)
626+
})
627+
}
628+
629+
func TestEventJSON_RoundTrip(t *testing.T) {
630+
t.Run("normal", func(t *testing.T) {
631+
src := &Event{
632+
Response: &model.Response{
633+
ID: "resp-rt",
634+
Object: model.ObjectTypeChatCompletion,
635+
Done: true,
636+
Choices: []model.Choice{{Index: 0, Message: model.NewAssistantMessage("hi")}},
637+
},
638+
ID: "evt-rt",
639+
InvocationID: "inv-rt",
640+
Author: "assistant",
641+
Timestamp: time.Now(),
642+
}
643+
644+
data, err := json.Marshal(src)
645+
require.NoError(t, err)
646+
647+
var dst Event
648+
require.NoError(t, json.Unmarshal(data, &dst))
649+
650+
require.NotNil(t, dst.Response)
651+
require.Equal(t, "evt-rt", dst.ID)
652+
require.Equal(t, "resp-rt", dst.Response.ID)
653+
require.Equal(t, model.ObjectTypeChatCompletion, dst.Response.Object)
654+
})
655+
t.Run("top-level value", func(t *testing.T) {
656+
e := Event{ID: "id1", Response: &model.Response{ID: "id2"}}
657+
data, err := json.Marshal(e)
658+
require.NoError(t, err)
659+
var dst Event
660+
require.NoError(t, json.Unmarshal(data, &dst))
661+
require.Equal(t, "id1", dst.ID)
662+
require.Equal(t, "id2", dst.Response.ID)
663+
})
664+
t.Run("top-level pointer", func(t *testing.T) {
665+
e := &Event{ID: "id1", Response: &model.Response{ID: "id2"}}
666+
data, err := json.Marshal(e)
667+
require.NoError(t, err)
668+
var dst Event
669+
require.NoError(t, json.Unmarshal(data, &dst))
670+
require.Equal(t, "id1", dst.ID)
671+
require.Equal(t, "id2", dst.Response.ID)
672+
})
673+
t.Run("slice element value", func(t *testing.T) {
674+
in := []Event{{ID: "id1", Response: &model.Response{ID: "id2"}}}
675+
data, err := json.Marshal(in)
676+
require.NoError(t, err)
677+
var out []Event
678+
require.NoError(t, json.Unmarshal(data, &out))
679+
require.Len(t, out, 1)
680+
require.Equal(t, "id2", out[0].Response.ID)
681+
})
682+
t.Run("map value non-addressable", func(t *testing.T) {
683+
m := map[string]Event{
684+
"k": {ID: "id1", Response: &model.Response{ID: "id2"}},
685+
}
686+
data, err := json.Marshal(m)
687+
require.NoError(t, err)
688+
var out map[string]Event
689+
require.NoError(t, json.Unmarshal(data, &out))
690+
require.Equal(t, "id2", out["k"].Response.ID)
691+
})
692+
t.Run("omit key and stay nil on roundtrip", func(t *testing.T) {
693+
e := Event{ID: "id1"}
694+
data, err := json.Marshal(e)
695+
require.NoError(t, err)
696+
697+
var tmp map[string]any
698+
require.NoError(t, json.Unmarshal(data, &tmp))
699+
_, has := tmp["response"]
700+
require.False(t, has)
701+
702+
var dst Event
703+
require.NoError(t, json.Unmarshal(data, &dst))
704+
require.Equal(t, "id1", dst.ID)
705+
require.Nil(t, dst.Response)
706+
})
707+
t.Run("timestamp round-trip", func(t *testing.T) {
708+
ts := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
709+
e := Event{ID: "id1", Response: &model.Response{ID: "id2", Timestamp: ts}}
710+
data, err := json.Marshal(e)
711+
require.NoError(t, err)
712+
var dst Event
713+
require.NoError(t, json.Unmarshal(data, &dst))
714+
require.True(t, dst.Response.Timestamp.Equal(ts))
715+
})
716+
t.Run("prefer nested over legacy", func(t *testing.T) {
717+
raw := []byte(`{
718+
"id": "id1",
719+
"Response": {"id": "old", "timestamp": "2024-01-01T00:00:00Z"},
720+
"response": {"id": "new", "timestamp": "2024-01-02T00:00:00Z"}
721+
}`)
722+
var dst Event
723+
require.NoError(t, json.Unmarshal(raw, &dst))
724+
require.Equal(t, "id1", dst.ID)
725+
require.Equal(t, "new", dst.Response.ID)
726+
require.Equal(t, time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC), dst.Response.Timestamp)
727+
})
728+
t.Run("malformed nested response is ignored, flat fields still decode", func(t *testing.T) {
729+
raw := []byte(`{"id":"id1","response":"oops"}`)
730+
var dst Event
731+
require.NoError(t, json.Unmarshal(raw, &dst))
732+
require.Equal(t, "id1", dst.ID)
733+
require.Nil(t, dst.Response)
734+
})
735+
}

0 commit comments

Comments
 (0)