From 8b63344fce34ca72601be2fe9baca6b8091c35f3 Mon Sep 17 00:00:00 2001 From: Henry Stern Date: Sat, 20 Jun 2026 22:41:57 -0300 Subject: [PATCH] add Events.Typed for registry-backed typed event access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The events container has held its members as raw json.RawMessage since the wire-shape phase, and the event-type registry (Event, EventDecoder, RegisterEventType, LookupEventType) landed alongside it. This connects the two: Events.Typed decodes a single events-claim member through the registry, turning a recognized event type's raw payload into the typed Event its vocabulary registered. The method distinguishes four outcomes, and the returned bool is true in exactly one of them. An absent member and a present-but-unregistered member both report (nil, false, nil) — an event type this build does not recognize is the expected forward-compatibility case (RFC 8417 §2.2), not an error, and its bytes stay reachable through Raw so the byte-stable round-trip is preserved. A present member whose registered decoder succeeds returns (event, true, nil). A present member whose decoder fails returns (nil, false, err), wrapping the decoder's error with %w and the event-type URI so errors.Is/As reach the cause and the message names which event failed. No enumeration helper is added: ranging the keys of Raw and calling Typed per URI reads cleanly and needs no new surface, so the godoc documents that pattern rather than introducing a URIs method. Decoding is left zero-copy — the raw bytes are handed to the decoder as-is, per the decoder contract that forbids mutating them — to keep the no-deserialization-cost property of unread events. Tests register distinct event-type URIs per case so they stay order-independent under -shuffle against the process-wide registry, and cover all four documented outcomes plus a nil receiver, errors.Is matching on a decoder sentinel, errors.As unwrapping to a concrete JSON error, and the documented range-over-Raw iteration. Co-Authored-By: Claude --- events.go | 59 +++++++++++++++- events_test.go | 183 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 239 insertions(+), 3 deletions(-) diff --git a/events.go b/events.go index becbba7..dd70c5c 100644 --- a/events.go +++ b/events.go @@ -3,7 +3,10 @@ package secevent -import "encoding/json" +import ( + "encoding/json" + "fmt" +) // Events is the RFC 8417 §2.2 "events" claim: a JSON object whose members map // an event-type URI to that event's payload. It is the defining claim of a @@ -14,8 +17,8 @@ import "encoding/json" // value. This preserves the payload bytes verbatim, so an event-type URI this // build does not recognize survives a decode/encode round-trip unchanged — the // forward-compatibility contract CAEP, RISC, and other vocabularies depend on. -// Typed access to a known event is provided through the event-type registry -// (a later building block); this type is the raw container only. +// Typed access to a known event is provided by the Typed method, which decodes +// a member through the event-type registry; Raw exposes the underlying bytes. // // json.RawMessage is used instead of map[string]any deliberately: interop // scenarios pin exact JSON bytes, and a map reorders its keys on every encode. @@ -35,3 +38,53 @@ func (e Events) Raw() map[string]json.RawMessage { func (e Events) Len() int { return len(e) } + +// Typed decodes the single events-claim member keyed by uri into a typed Event, +// using the decoder registered for uri (see RegisterEventType). It distinguishes +// four cases, and the returned bool is true in exactly one of them: +// +// - uri is not a member of the events claim: returns (nil, false, nil). The +// event simply is not present. +// - uri is present but no decoder is registered for it (an event type this +// build does not recognize): returns (nil, false, nil). The member stays +// raw — reach it via Raw()[uri] — preserving the byte-stable round-trip that +// unknown event types depend on. An unregistered URI is the expected case, +// not an error. +// - uri is present and its registered decoder succeeds: returns +// (event, true, nil). This is the only case in which ok is true and a +// non-nil Event is returned. +// - uri is present and its registered decoder fails: returns (nil, false, +// err), where err wraps the decoder's error with the event-type URI for +// context. Inspect it with errors.Is or errors.As. +// +// To walk every event in the claim — decoding the recognized ones and leaving +// the rest raw — range over the keys of Raw and call Typed per URI: +// +// for uri := range e.Raw() { +// event, ok, err := e.Typed(uri) +// switch { +// case err != nil: +// // the registered decoder rejected the payload +// case ok: +// // event is the typed value +// default: +// // no decoder registered; e.Raw()[uri] holds the raw bytes +// } +// } +func (e Events) Typed(uri string) (Event, bool, error) { + raw, present := e[uri] + if !present { + return nil, false, nil + } + + decode, registered := LookupEventType(uri) + if !registered { + return nil, false, nil + } + + event, err := decode(raw) + if err != nil { + return nil, false, fmt.Errorf("secevent: decode event %s: %w", uri, err) + } + return event, true, nil +} diff --git a/events_test.go b/events_test.go index a429af7..fd807b8 100644 --- a/events_test.go +++ b/events_test.go @@ -6,6 +6,8 @@ package secevent import ( "bytes" "encoding/json" + "errors" + "strings" "testing" ) @@ -101,3 +103,184 @@ func TestEventsRawMutationVisible(t *testing.T) { t.Errorf("Len() after Raw() mutation = %d, want 2", ev.Len()) } } + +// TestTypedRegistered covers the one case in which Typed returns a typed value: +// the URI is present in the events claim and a decoder is registered for it. A +// distinct, freshly-registered URI keeps the process-wide registry state +// independent of other tests under -shuffle (fakeEvent / decodeFake are the +// test-local fixtures from registry_test.go). +func TestTypedRegistered(t *testing.T) { + const uri = "https://schemas.example.com/secevent/test/event-type/typed-registered" + RegisterEventType(uri, decodeFake(uri)) + + ev := Events{uri: json.RawMessage(`{"subject":"alice"}`)} + + event, ok, err := ev.Typed(uri) + if err != nil { + t.Fatalf("Typed(%q) err = %v, want nil", uri, err) + } + if !ok { + t.Fatalf("Typed(%q) ok = false, want true for a registered, decodable member", uri) + } + if event == nil { + t.Fatalf("Typed(%q) returned nil event with ok = true", uri) + } + if got := event.EventTypeURI(); got != uri { + t.Errorf("decoded event EventTypeURI() = %q, want %q", got, uri) + } + fe, isFake := event.(fakeEvent) + if !isFake { + t.Fatalf("decoded event has type %T, want fakeEvent", event) + } + if fe.Subject != "alice" { + t.Errorf("decoded Subject = %q, want %q", fe.Subject, "alice") + } +} + +// TestTypedUnregisteredPresent covers a present member whose URI has no +// registered decoder: an event type this build does not recognize. Typed +// reports (nil, false, nil) — not an error — and the raw bytes stay reachable +// through Raw, preserving the byte-stable-unknown-events guarantee. +func TestTypedUnregisteredPresent(t *testing.T) { + const uri = "https://schemas.example.com/secevent/test/event-type/typed-unregistered" + payload := json.RawMessage(`{"opaque":["x",1,true],"order":"preserved"}`) + ev := Events{uri: payload} + + event, ok, err := ev.Typed(uri) + if err != nil { + t.Fatalf("Typed(%q) err = %v, want nil for an unregistered URI", uri, err) + } + if ok { + t.Errorf("Typed(%q) ok = true, want false for an unregistered URI", uri) + } + if event != nil { + t.Errorf("Typed(%q) event = %v, want nil when ok = false", uri, event) + } + if got := ev.Raw()[uri]; !bytes.Equal(got, payload) { + t.Errorf("raw payload after Typed changed:\n got %s\nwant %s", got, payload) + } +} + +// TestTypedAbsent covers a URI that is not a member of the events claim at all: +// Typed reports (nil, false, nil), the same not-typed signal as an unregistered +// present member, distinguished by the member's absence from Raw. +func TestTypedAbsent(t *testing.T) { + const present = "https://schemas.example.com/secevent/test/event-type/typed-absent-present" + const absent = "https://schemas.example.com/secevent/test/event-type/typed-absent-missing" + ev := Events{present: json.RawMessage(`{}`)} + + event, ok, err := ev.Typed(absent) + if err != nil { + t.Fatalf("Typed(%q) err = %v, want nil for an absent URI", absent, err) + } + if ok { + t.Errorf("Typed(%q) ok = true, want false for an absent URI", absent) + } + if event != nil { + t.Errorf("Typed(%q) event = %v, want nil for an absent URI", absent, event) + } + if _, ok := ev.Raw()[absent]; ok { + t.Errorf("absent URI %q unexpectedly present in Raw()", absent) + } +} + +// TestTypedNilReceiver documents that Typed is safe on a nil Events: indexing a +// nil map yields not-present, so any URI reports (nil, false, nil) rather than +// panicking. This matches Len and Raw, which also tolerate a nil receiver. +func TestTypedNilReceiver(t *testing.T) { + var ev Events + event, ok, err := ev.Typed("https://schemas.example.com/secevent/test/event-type/nil-recv") + if event != nil || ok || err != nil { + t.Errorf("nil Events Typed = (%v, %v, %v), want (nil, false, nil)", event, ok, err) + } +} + +// errDecodeFailed is a sentinel a failing decoder returns so the test can match +// the wrapped error with errors.Is — confirming Typed wraps with %w rather than +// flattening the decoder's error. +var errDecodeFailed = errors.New("fake decoder rejected payload") + +// TestTypedDecoderError covers a present, registered member whose decoder +// fails: Typed returns (nil, false, err), and err wraps the decoder's error +// (matchable with errors.Is) with the event-type URI for context. +func TestTypedDecoderError(t *testing.T) { + const uri = "https://schemas.example.com/secevent/test/event-type/typed-decoder-error" + RegisterEventType(uri, func(json.RawMessage) (Event, error) { + return nil, errDecodeFailed + }) + + ev := Events{uri: json.RawMessage(`{"subject":"bob"}`)} + + event, ok, err := ev.Typed(uri) + if err == nil { + t.Fatalf("Typed(%q) err = nil, want a wrapped decode error", uri) + } + if ok { + t.Errorf("Typed(%q) ok = true, want false when the decoder fails", uri) + } + if event != nil { + t.Errorf("Typed(%q) event = %v, want nil when the decoder fails", uri, event) + } + if !errors.Is(err, errDecodeFailed) { + t.Errorf("Typed error does not wrap the decoder error: %v", err) + } + if !strings.Contains(err.Error(), uri) { + t.Errorf("Typed error %q does not mention the event-type URI %q", err, uri) + } +} + +// TestTypedDecoderErrorAsJSON confirms the wrapped error also unwraps to a +// concrete error type via errors.As, using a decoder that surfaces the +// json.Unmarshal failure on malformed payload bytes (decodeFake's behavior). +func TestTypedDecoderErrorAsJSON(t *testing.T) { + const uri = "https://schemas.example.com/secevent/test/event-type/typed-decoder-error-as" + RegisterEventType(uri, decodeFake(uri)) + + // "subject" is declared as a string on fakeEvent; a number is a type error. + ev := Events{uri: json.RawMessage(`{"subject":12345}`)} + + _, ok, err := ev.Typed(uri) + if ok || err == nil { + t.Fatalf("Typed(%q) = (_, %v, %v), want (nil, false, non-nil)", uri, ok, err) + } + var typeErr *json.UnmarshalTypeError + if !errors.As(err, &typeErr) { + t.Errorf("Typed error does not unwrap to *json.UnmarshalTypeError: %v", err) + } +} + +// TestTypedIterationOverRaw exercises the documented iteration pattern: ranging +// Raw and calling Typed per URI decodes the registered events and leaves the +// unknown ones raw, in a single pass over a mixed container. +func TestTypedIterationOverRaw(t *testing.T) { + const knownURI = "https://schemas.example.com/secevent/test/event-type/iter-known" + const unknownURI = "https://schemas.example.com/secevent/test/event-type/iter-unknown" + RegisterEventType(knownURI, decodeFake(knownURI)) + + unknownPayload := json.RawMessage(`{"still":"raw"}`) + ev := Events{ + knownURI: json.RawMessage(`{"subject":"carol"}`), + unknownURI: unknownPayload, + } + + typed := make(map[string]Event) + rawLeft := make(map[string]json.RawMessage) + for uri := range ev.Raw() { + event, ok, err := ev.Typed(uri) + switch { + case err != nil: + t.Fatalf("Typed(%q) err = %v, want nil", uri, err) + case ok: + typed[uri] = event + default: + rawLeft[uri] = ev.Raw()[uri] + } + } + + if len(typed) != 1 || typed[knownURI] == nil { + t.Errorf("typed = %v, want exactly the known URI decoded", typed) + } + if len(rawLeft) != 1 || !bytes.Equal(rawLeft[unknownURI], unknownPayload) { + t.Errorf("rawLeft = %v, want exactly the unknown URI left raw", rawLeft) + } +}