Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 56 additions & 3 deletions events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
}
183 changes: 183 additions & 0 deletions events_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ package secevent
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
)

Expand Down Expand Up @@ -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)
}
}
Loading