13 // Schema is the supported envelope and payload schema version.
16 // MeasurementRepeatInterval bounds equal retained measurement repeats.
17 MeasurementRepeatInterval = 5 * time.Minute
18 // OnlineRepeatInterval bounds equal online availability repeats.
19 OnlineRepeatInterval = 5 * time.Minute
20 // DeviceContactTimeout bounds the age of device contact evidence.
21 DeviceContactTimeout = 15 * time.Minute
24 // Metadata holds message identity and optional UTC times. Retries keep the same
25 // ID. A receiver checks ExpiresAt against its clock before processing.
26 type Metadata struct {
27 MessageID string `json:"message_id"`
28 SentAt *time.Time `json:"sent_at,omitempty"`
29 ExpiresAt *time.Time `json:"expires_at,omitempty"`
30 CorrelationID string `json:"correlation_id,omitempty"`
31 CausationID string `json:"causation_id,omitempty"`
34 // Envelope carries one JSON object and its schema and metadata.
35 type Envelope struct {
36 Schema int `json:"schema"`
38 Payload json.RawMessage `json:"payload"`
41 // EncodeEnvelope validates metadata and encodes a schema-1 object payload.
42 func EncodeEnvelope(metadata Metadata, payload any) ([]byte, error) {
43 encoded, err := json.Marshal(payload)
45 return nil, fmt.Errorf("encode payload: %w", err)
48 envelope := Envelope{Schema: Schema, Metadata: metadata, Payload: encoded}
49 err = validateEnvelope(envelope)
51 return nil, fmt.Errorf("validate envelope: %w", err)
54 encoded, err = json.Marshal(envelope)
56 return nil, fmt.Errorf("encode envelope: %w", err)
62 // ParseEnvelope rejects unknown fields, trailing JSON, unsupported schemas,
63 // invalid IDs, non-UTC times, and missing or non-object payloads.
64 func ParseEnvelope(data []byte) (Envelope, error) {
67 err := decodeObject(data, &envelope)
69 return Envelope{}, fmt.Errorf("decode envelope: %w", err)
72 err = validateEnvelope(envelope)
74 return Envelope{}, fmt.Errorf("validate envelope: %w", err)
80 func validateEnvelope(envelope Envelope) error {
81 if envelope.Schema != Schema {
82 return fmt.Errorf("unsupported schema %d", envelope.Schema)
85 if !ValidID(envelope.MessageID) {
86 return fmt.Errorf("message ID must be a canonical ULID")
89 if envelope.CorrelationID != "" && !ValidID(envelope.CorrelationID) {
90 return fmt.Errorf("correlation ID must be a canonical ULID")
93 if envelope.CausationID != "" && !ValidID(envelope.CausationID) {
94 return fmt.Errorf("causation ID must be a canonical ULID")
97 for _, timestamp := range []*time.Time{envelope.SentAt, envelope.ExpiresAt} {
99 _, offset := timestamp.Zone()
101 return fmt.Errorf("message times must use UTC")
106 if !isObject(envelope.Payload) {
107 return fmt.Errorf("payload must be a JSON object")
113 func decodeObject(data []byte, target any) error {
115 return fmt.Errorf("expected one JSON object")
118 decoder := json.NewDecoder(bytes.NewReader(data))
119 decoder.DisallowUnknownFields()
121 err := decoder.Decode(target)
123 return fmt.Errorf("decode object: %w", err)
128 err = decoder.Decode(&extra)
129 if !errors.Is(err, io.EOF) {
130 return fmt.Errorf("expected end of JSON object")
136 func isObject(data []byte) bool {
137 data = bytes.TrimSpace(data)
138 return len(data) > 0 && data[0] == '{' && json.Valid(data)