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) + } +}