// Package notificationintent defines the shared producer-facing contract for // publishing normalized notification intents into Notification Service. package notificationintent import ( "bytes" "encoding/json" "errors" "fmt" "io" "sort" "strconv" "strings" "time" ) const ( fieldNotificationType = "notification_type" fieldProducer = "producer" fieldAudienceKind = "audience_kind" fieldRecipientUserIDs = "recipient_user_ids_json" fieldIdempotencyKey = "idempotency_key" fieldOccurredAtMS = "occurred_at_ms" fieldRequestID = "request_id" fieldTraceID = "trace_id" fieldPayloadJSON = "payload_json" // DefaultIntentsStream stores the frozen Redis Stream name consumed by // Notification Service. DefaultIntentsStream = "notification:intents" ) var ( requiredFieldNames = map[string]struct{}{ fieldNotificationType: {}, fieldProducer: {}, fieldAudienceKind: {}, fieldIdempotencyKey: {}, fieldOccurredAtMS: {}, fieldPayloadJSON: {}, } optionalFieldNames = map[string]struct{}{ fieldRecipientUserIDs: {}, fieldRequestID: {}, fieldTraceID: {}, } ) // NotificationType identifies one supported normalized notification type. type NotificationType string const ( // NotificationTypeGeoReviewRecommended identifies the // `geo.review_recommended` notification. NotificationTypeGeoReviewRecommended NotificationType = "geo.review_recommended" // NotificationTypeGameTurnReady identifies the `game.turn.ready` // notification. NotificationTypeGameTurnReady NotificationType = "game.turn.ready" // NotificationTypeGameFinished identifies the `game.finished` // notification. NotificationTypeGameFinished NotificationType = "game.finished" // NotificationTypeGameGenerationFailed identifies the // `game.generation_failed` notification. NotificationTypeGameGenerationFailed NotificationType = "game.generation_failed" // NotificationTypeLobbyRuntimePausedAfterStart identifies the // `lobby.runtime_paused_after_start` notification. NotificationTypeLobbyRuntimePausedAfterStart NotificationType = "lobby.runtime_paused_after_start" // NotificationTypeLobbyApplicationSubmitted identifies the // `lobby.application.submitted` notification. NotificationTypeLobbyApplicationSubmitted NotificationType = "lobby.application.submitted" // NotificationTypeLobbyMembershipApproved identifies the // `lobby.membership.approved` notification. NotificationTypeLobbyMembershipApproved NotificationType = "lobby.membership.approved" // NotificationTypeLobbyMembershipRejected identifies the // `lobby.membership.rejected` notification. NotificationTypeLobbyMembershipRejected NotificationType = "lobby.membership.rejected" // NotificationTypeLobbyMembershipBlocked identifies the // `lobby.membership.blocked` notification published by Game Lobby // to the private-game owner when an active membership is blocked // by the user-lifecycle cascade reacting to a `permanent_block` or // `DeleteUser` event. NotificationTypeLobbyMembershipBlocked NotificationType = "lobby.membership.blocked" // NotificationTypeLobbyInviteCreated identifies the // `lobby.invite.created` notification. NotificationTypeLobbyInviteCreated NotificationType = "lobby.invite.created" // NotificationTypeLobbyInviteRedeemed identifies the // `lobby.invite.redeemed` notification. NotificationTypeLobbyInviteRedeemed NotificationType = "lobby.invite.redeemed" // NotificationTypeLobbyInviteExpired identifies the // `lobby.invite.expired` notification. NotificationTypeLobbyInviteExpired NotificationType = "lobby.invite.expired" // NotificationTypeLobbyRaceNameRegistrationEligible identifies the // `lobby.race_name.registration_eligible` notification published by // Game Lobby when capability evaluation at game finish promotes a // reservation to `pending_registration`. NotificationTypeLobbyRaceNameRegistrationEligible NotificationType = "lobby.race_name.registration_eligible" // NotificationTypeLobbyRaceNameRegistered identifies the // `lobby.race_name.registered` notification published by Game Lobby // when a user converts a `pending_registration` into a permanent // registered race name. NotificationTypeLobbyRaceNameRegistered NotificationType = "lobby.race_name.registered" // NotificationTypeLobbyRaceNameRegistrationDenied identifies the // `lobby.race_name.registration_denied` notification published by // Game Lobby when capability evaluation at game finish releases a // reservation because the member did not meet the capability rule. NotificationTypeLobbyRaceNameRegistrationDenied NotificationType = "lobby.race_name.registration_denied" ) // String returns the wire value for notificationType. func (notificationType NotificationType) String() string { return string(notificationType) } // IsKnown reports whether notificationType belongs to the frozen catalog. func (notificationType NotificationType) IsKnown() bool { switch notificationType { case NotificationTypeGeoReviewRecommended, NotificationTypeGameTurnReady, NotificationTypeGameFinished, NotificationTypeGameGenerationFailed, NotificationTypeLobbyRuntimePausedAfterStart, NotificationTypeLobbyApplicationSubmitted, NotificationTypeLobbyMembershipApproved, NotificationTypeLobbyMembershipRejected, NotificationTypeLobbyMembershipBlocked, NotificationTypeLobbyInviteCreated, NotificationTypeLobbyInviteRedeemed, NotificationTypeLobbyInviteExpired, NotificationTypeLobbyRaceNameRegistrationEligible, NotificationTypeLobbyRaceNameRegistered, NotificationTypeLobbyRaceNameRegistrationDenied: return true default: return false } } // ExpectedProducer returns the frozen producer for notificationType. func (notificationType NotificationType) ExpectedProducer() Producer { switch notificationType { case NotificationTypeGeoReviewRecommended: return ProducerGeoProfile case NotificationTypeGameTurnReady, NotificationTypeGameFinished, NotificationTypeGameGenerationFailed: return ProducerGameMaster case NotificationTypeLobbyRuntimePausedAfterStart, NotificationTypeLobbyApplicationSubmitted, NotificationTypeLobbyMembershipApproved, NotificationTypeLobbyMembershipRejected, NotificationTypeLobbyMembershipBlocked, NotificationTypeLobbyInviteCreated, NotificationTypeLobbyInviteRedeemed, NotificationTypeLobbyInviteExpired, NotificationTypeLobbyRaceNameRegistrationEligible, NotificationTypeLobbyRaceNameRegistered, NotificationTypeLobbyRaceNameRegistrationDenied: return ProducerGameLobby default: return "" } } // SupportsAudience reports whether notificationType supports audienceKind. func (notificationType NotificationType) SupportsAudience(audienceKind AudienceKind) bool { switch notificationType { case NotificationTypeGeoReviewRecommended, NotificationTypeGameGenerationFailed, NotificationTypeLobbyRuntimePausedAfterStart: return audienceKind == AudienceKindAdminEmail case NotificationTypeLobbyApplicationSubmitted: return audienceKind == AudienceKindUser || audienceKind == AudienceKindAdminEmail default: return audienceKind == AudienceKindUser } } // SupportsChannel reports whether notificationType uses channel for // audienceKind. func (notificationType NotificationType) SupportsChannel(audienceKind AudienceKind, channel Channel) bool { switch notificationType { case NotificationTypeGeoReviewRecommended, NotificationTypeGameGenerationFailed, NotificationTypeLobbyRuntimePausedAfterStart: return audienceKind == AudienceKindAdminEmail && channel == ChannelEmail case NotificationTypeLobbyApplicationSubmitted: if audienceKind == AudienceKindAdminEmail { return channel == ChannelEmail } return channel == ChannelPush || channel == ChannelEmail case NotificationTypeLobbyInviteExpired, NotificationTypeLobbyRaceNameRegistrationDenied: return audienceKind == AudienceKindUser && channel == ChannelEmail default: return audienceKind == AudienceKindUser && (channel == ChannelPush || channel == ChannelEmail) } } // Producer identifies one supported upstream producer. type Producer string const ( // ProducerGeoProfile identifies Geo Profile Service. ProducerGeoProfile Producer = "geoprofile" // ProducerGameMaster identifies Game Master. ProducerGameMaster Producer = "game_master" // ProducerGameLobby identifies Game Lobby. ProducerGameLobby Producer = "game_lobby" ) // String returns the wire value for producer. func (producer Producer) String() string { return string(producer) } // IsKnown reports whether producer belongs to the frozen producer set. func (producer Producer) IsKnown() bool { switch producer { case ProducerGeoProfile, ProducerGameMaster, ProducerGameLobby: return true default: return false } } // AudienceKind identifies one supported target-audience kind. type AudienceKind string const ( // AudienceKindUser identifies user-targeted notifications. AudienceKindUser AudienceKind = "user" // AudienceKindAdminEmail identifies administrator-email notifications. AudienceKindAdminEmail AudienceKind = "admin_email" ) // String returns the wire value for audienceKind. func (audienceKind AudienceKind) String() string { return string(audienceKind) } // IsKnown reports whether audienceKind belongs to the frozen audience set. func (audienceKind AudienceKind) IsKnown() bool { switch audienceKind { case AudienceKindUser, AudienceKindAdminEmail: return true default: return false } } // Channel identifies one durable notification-delivery channel slot. type Channel string const ( // ChannelPush identifies the push-delivery channel. ChannelPush Channel = "push" // ChannelEmail identifies the email-delivery channel. ChannelEmail Channel = "email" ) // String returns the wire value for channel. func (channel Channel) String() string { return string(channel) } // IsKnown reports whether channel belongs to the frozen channel vocabulary. func (channel Channel) IsKnown() bool { switch channel { case ChannelPush, ChannelEmail: return true default: return false } } // Metadata stores producer-owned envelope fields shared by every notification // intent. type Metadata struct { // IdempotencyKey stores the producer-owned idempotency key scoped together // with the producer name. IdempotencyKey string // OccurredAt stores when the producer says the underlying business event // happened. Constructors normalize the value to UTC millisecond precision. OccurredAt time.Time // RequestID stores the optional producer-side request identifier. RequestID string // TraceID stores the optional producer-side trace identifier. TraceID string } // Intent stores one normalized notification intent accepted by Notification // Service. type Intent struct { // NotificationType stores the frozen notification vocabulary value. NotificationType NotificationType // Producer stores the frozen producer identifier. Producer Producer // AudienceKind stores the normalized target audience kind. AudienceKind AudienceKind // RecipientUserIDs stores the normalized sorted unique user-recipient set // when AudienceKind is AudienceKindUser. RecipientUserIDs []string // IdempotencyKey stores the producer-owned idempotency key. IdempotencyKey string // OccurredAt stores when the producer says the underlying business event // happened. OccurredAt time.Time // RequestID stores the optional producer-side request identifier. RequestID string // TraceID stores the optional producer-side trace identifier. TraceID string // PayloadJSON stores the canonical normalized payload JSON string used for // duplicate detection. PayloadJSON string } // Validate reports whether intent contains a complete normalized intake // request. func (intent Intent) Validate() error { if !intent.NotificationType.IsKnown() { return fmt.Errorf("intent notification type %q is unsupported", intent.NotificationType) } if !intent.Producer.IsKnown() { return fmt.Errorf("intent producer %q is unsupported", intent.Producer) } if expected := intent.NotificationType.ExpectedProducer(); intent.Producer != expected { return fmt.Errorf( "intent producer %q does not match notification type %q", intent.Producer, intent.NotificationType, ) } if !intent.AudienceKind.IsKnown() { return fmt.Errorf("intent audience kind %q is unsupported", intent.AudienceKind) } if !intent.NotificationType.SupportsAudience(intent.AudienceKind) { return fmt.Errorf( "intent notification type %q does not support audience kind %q", intent.NotificationType, intent.AudienceKind, ) } if strings.TrimSpace(intent.IdempotencyKey) == "" { return errors.New("intent idempotency key must not be empty") } if err := validateTimestamp("intent occurred at", intent.OccurredAt); err != nil { return err } if strings.TrimSpace(intent.PayloadJSON) == "" { return errors.New("intent payload json must not be empty") } switch intent.AudienceKind { case AudienceKindUser: if len(intent.RecipientUserIDs) == 0 { return errors.New("intent recipient user ids must not be empty for audience kind user") } for index, userID := range intent.RecipientUserIDs { if userID == "" { return fmt.Errorf("intent recipient user ids[%d] must not be empty", index) } if index > 0 && intent.RecipientUserIDs[index-1] >= userID { return errors.New("intent recipient user ids must be sorted strictly ascending") } } case AudienceKindAdminEmail: if len(intent.RecipientUserIDs) > 0 { return errors.New("intent recipient user ids must be empty for audience kind admin_email") } } return nil } // Values returns Redis Stream field values for intent. It validates and // normalizes the recipient set, event timestamp, and payload before building // the field map. func (intent Intent) Values() (map[string]any, error) { normalized, err := normalizeIntent(intent) if err != nil { return nil, err } values := map[string]any{ fieldNotificationType: normalized.NotificationType.String(), fieldProducer: normalized.Producer.String(), fieldAudienceKind: normalized.AudienceKind.String(), fieldIdempotencyKey: normalized.IdempotencyKey, fieldOccurredAtMS: strconv.FormatInt(normalized.OccurredAt.UnixMilli(), 10), fieldPayloadJSON: normalized.PayloadJSON, } if normalized.AudienceKind == AudienceKindUser { recipientUserIDs, err := json.Marshal(normalized.RecipientUserIDs) if err != nil { return nil, fmt.Errorf("marshal recipient_user_ids_json: %w", err) } values[fieldRecipientUserIDs] = string(recipientUserIDs) } if normalized.RequestID != "" { values[fieldRequestID] = normalized.RequestID } if normalized.TraceID != "" { values[fieldTraceID] = normalized.TraceID } return values, nil } // DecodeIntent validates one raw Redis Stream entry and returns the normalized // notification intent frozen by the producer contract. func DecodeIntent(fields map[string]any) (Intent, error) { if fields == nil { return Intent{}, errors.New("intent fields must not be nil") } if err := validateFieldSet(fields); err != nil { return Intent{}, err } notificationTypeValue, err := requiredString(fields, fieldNotificationType) if err != nil { return Intent{}, err } producerValue, err := requiredString(fields, fieldProducer) if err != nil { return Intent{}, err } audienceKindValue, err := requiredString(fields, fieldAudienceKind) if err != nil { return Intent{}, err } idempotencyKeyValue, err := requiredString(fields, fieldIdempotencyKey) if err != nil { return Intent{}, err } occurredAtValue, err := requiredString(fields, fieldOccurredAtMS) if err != nil { return Intent{}, err } payloadJSONValue, err := requiredString(fields, fieldPayloadJSON) if err != nil { return Intent{}, err } intent := Intent{ NotificationType: NotificationType(notificationTypeValue), Producer: Producer(producerValue), AudienceKind: AudienceKind(audienceKindValue), IdempotencyKey: idempotencyKeyValue, } if requestIDValue, ok, err := optionalString(fields, fieldRequestID); err != nil { return Intent{}, err } else if ok { intent.RequestID = requestIDValue } if traceIDValue, ok, err := optionalString(fields, fieldTraceID); err != nil { return Intent{}, err } else if ok { intent.TraceID = traceIDValue } occurredAt, err := parseUnixMilliseconds(occurredAtValue) if err != nil { return Intent{}, err } intent.OccurredAt = occurredAt if !intent.NotificationType.IsKnown() { return Intent{}, fmt.Errorf("stream field %q value %q is unsupported", fieldNotificationType, notificationTypeValue) } if !intent.Producer.IsKnown() { return Intent{}, fmt.Errorf("stream field %q value %q is unsupported", fieldProducer, producerValue) } if !intent.AudienceKind.IsKnown() { return Intent{}, fmt.Errorf("stream field %q value %q is unsupported", fieldAudienceKind, audienceKindValue) } if intent.NotificationType.ExpectedProducer() != intent.Producer { return Intent{}, fmt.Errorf( "stream field %q value %q does not match notification type %q", fieldProducer, producerValue, intent.NotificationType, ) } if !intent.NotificationType.SupportsAudience(intent.AudienceKind) { return Intent{}, fmt.Errorf( "stream field %q value %q is unsupported for notification type %q", fieldAudienceKind, audienceKindValue, intent.NotificationType, ) } switch intent.AudienceKind { case AudienceKindUser: recipientUserIDsValue, err := requiredString(fields, fieldRecipientUserIDs) if err != nil { return Intent{}, err } recipientUserIDs, err := normalizeRecipientUserIDs(recipientUserIDsValue) if err != nil { return Intent{}, err } intent.RecipientUserIDs = recipientUserIDs case AudienceKindAdminEmail: if _, found := fields[fieldRecipientUserIDs]; found { return Intent{}, fmt.Errorf("stream field %q must not be present for audience kind %q", fieldRecipientUserIDs, intent.AudienceKind) } } canonicalPayloadJSON, err := validateAndNormalizePayload(intent.NotificationType, payloadJSONValue) if err != nil { return Intent{}, err } intent.PayloadJSON = canonicalPayloadJSON if err := intent.Validate(); err != nil { return Intent{}, err } return intent, nil } func newIntent( notificationType NotificationType, producer Producer, audienceKind AudienceKind, recipientUserIDs []string, metadata Metadata, payload any, ) (Intent, error) { payloadJSON, err := json.Marshal(payload) if err != nil { return Intent{}, fmt.Errorf("marshal payload_json: %w", err) } return normalizeIntent(Intent{ NotificationType: notificationType, Producer: producer, AudienceKind: audienceKind, RecipientUserIDs: append([]string(nil), recipientUserIDs...), IdempotencyKey: metadata.IdempotencyKey, OccurredAt: normalizeTimestamp(metadata.OccurredAt), RequestID: metadata.RequestID, TraceID: metadata.TraceID, PayloadJSON: string(payloadJSON), }) } func normalizeIntent(intent Intent) (Intent, error) { normalized := intent normalized.OccurredAt = normalizeTimestamp(intent.OccurredAt) switch normalized.AudienceKind { case AudienceKindUser: recipientUserIDs, err := normalizeRecipientUserIDValues(normalized.RecipientUserIDs) if err != nil { return Intent{}, err } normalized.RecipientUserIDs = recipientUserIDs case AudienceKindAdminEmail: if len(normalized.RecipientUserIDs) > 0 { return Intent{}, errors.New("intent recipient user ids must be empty for audience kind admin_email") } default: if len(normalized.RecipientUserIDs) > 0 { recipientUserIDs, err := normalizeRecipientUserIDValues(normalized.RecipientUserIDs) if err != nil { return Intent{}, err } normalized.RecipientUserIDs = recipientUserIDs } } canonicalPayloadJSON, err := validateAndNormalizePayload(normalized.NotificationType, normalized.PayloadJSON) if err != nil { return Intent{}, err } normalized.PayloadJSON = canonicalPayloadJSON if err := normalized.Validate(); err != nil { return Intent{}, err } return normalized, nil } func normalizeTimestamp(value time.Time) time.Time { if value.IsZero() { return value } return value.UTC().Truncate(time.Millisecond) } func validateFieldSet(fields map[string]any) error { missing := make([]string, 0, len(requiredFieldNames)) for name := range requiredFieldNames { if _, ok := fields[name]; !ok { missing = append(missing, name) } } sort.Strings(missing) if len(missing) > 0 { return fmt.Errorf("intent is missing required fields: %s", strings.Join(missing, ", ")) } unexpected := make([]string, 0) for name := range fields { if _, ok := requiredFieldNames[name]; ok { continue } if _, ok := optionalFieldNames[name]; ok { continue } unexpected = append(unexpected, name) } sort.Strings(unexpected) if len(unexpected) > 0 { return fmt.Errorf("intent contains unsupported fields: %s", strings.Join(unexpected, ", ")) } return nil } func requiredString(fields map[string]any, name string) (string, error) { value, ok := fields[name] if !ok { return "", fmt.Errorf("stream field %q is required", name) } result, ok := rawString(value) if !ok { return "", fmt.Errorf("stream field %q must be a string", name) } return result, nil } func optionalString(fields map[string]any, name string) (string, bool, error) { value, ok := fields[name] if !ok { return "", false, nil } result, ok := rawString(value) if !ok { return "", false, fmt.Errorf("stream field %q must be a string", name) } return result, true, nil } func rawString(value any) (string, bool) { switch typed := value.(type) { case string: return typed, true case []byte: return string(typed), true default: return "", false } } func parseUnixMilliseconds(raw string) (time.Time, error) { if raw == "" { return time.Time{}, fmt.Errorf("stream field %q must be a base-10 Unix milliseconds string", fieldOccurredAtMS) } for _, r := range raw { if r < '0' || r > '9' { return time.Time{}, fmt.Errorf("stream field %q must be a base-10 Unix milliseconds string", fieldOccurredAtMS) } } value, err := strconv.ParseInt(raw, 10, 64) if err != nil { return time.Time{}, fmt.Errorf("stream field %q must be a base-10 Unix milliseconds string", fieldOccurredAtMS) } return time.UnixMilli(value).UTC(), nil } func normalizeRecipientUserIDs(raw string) ([]string, error) { var values []string if err := decodeStrictJSON("decode recipient_user_ids_json", raw, &values, false); err != nil { return nil, err } return normalizeRecipientUserIDValues(values) } func normalizeRecipientUserIDValues(values []string) ([]string, error) { if len(values) == 0 { return nil, errors.New("recipient_user_ids_json must contain at least one user id") } seen := make(map[string]struct{}, len(values)) normalized := make([]string, 0, len(values)) for index, value := range values { if value == "" { return nil, fmt.Errorf("recipient_user_ids_json[%d] must not be empty", index) } if _, ok := seen[value]; ok { return nil, fmt.Errorf("recipient_user_ids_json[%d] duplicates user id %q", index, value) } seen[value] = struct{}{} normalized = append(normalized, value) } sort.Strings(normalized) return normalized, nil } func validateAndNormalizePayload(notificationType NotificationType, raw string) (string, error) { payloadObject, err := decodeJSONObjectRaw("decode payload_json", raw) if err != nil { return "", err } if err := validatePayloadObject(notificationType, payloadObject); err != nil { return "", err } normalizedValue, err := decodeNormalizedJSONValue("decode payload_json", raw) if err != nil { return "", err } normalizedPayload, err := json.Marshal(normalizedValue) if err != nil { return "", fmt.Errorf("normalize payload_json: %w", err) } return string(normalizedPayload), nil } func validatePayloadObject(notificationType NotificationType, payload map[string]json.RawMessage) error { switch notificationType { case NotificationTypeGeoReviewRecommended: return validateStringFields(payload, "user_id", "user_email", "observed_country", "usual_connection_country", "review_reason") case NotificationTypeGameTurnReady: if err := validateStringFields(payload, "game_id", "game_name"); err != nil { return err } return validatePositiveIntFields(payload, "turn_number") case NotificationTypeGameFinished: if err := validateStringFields(payload, "game_id", "game_name"); err != nil { return err } return validatePositiveIntFields(payload, "final_turn_number") case NotificationTypeGameGenerationFailed: return validateStringFields(payload, "game_id", "game_name", "failure_reason") case NotificationTypeLobbyRuntimePausedAfterStart: return validateStringFields(payload, "game_id", "game_name") case NotificationTypeLobbyApplicationSubmitted: return validateStringFields(payload, "game_id", "game_name", "applicant_user_id", "applicant_name") case NotificationTypeLobbyMembershipApproved, NotificationTypeLobbyMembershipRejected: return validateStringFields(payload, "game_id", "game_name") case NotificationTypeLobbyMembershipBlocked: return validateStringFields(payload, "game_id", "game_name", "membership_user_id", "membership_user_name", "reason") case NotificationTypeLobbyInviteCreated: return validateStringFields(payload, "game_id", "game_name", "inviter_user_id", "inviter_name") case NotificationTypeLobbyInviteRedeemed, NotificationTypeLobbyInviteExpired: return validateStringFields(payload, "game_id", "game_name", "invitee_user_id", "invitee_name") case NotificationTypeLobbyRaceNameRegistrationEligible: if err := validateStringFields(payload, "game_id", "game_name", "race_name"); err != nil { return err } return validatePositiveIntFields(payload, "eligible_until_ms") case NotificationTypeLobbyRaceNameRegistered: return validateStringFields(payload, "race_name") case NotificationTypeLobbyRaceNameRegistrationDenied: return validateStringFields(payload, "game_id", "game_name", "race_name", "reason") default: return fmt.Errorf("payload_json notification type %q is unsupported", notificationType) } } func validateStringFields(payload map[string]json.RawMessage, names ...string) error { for _, name := range names { var value string if err := decodeRequiredJSONField(payload, name, &value); err != nil { return err } if value == "" { return fmt.Errorf("payload_json.%s must not be empty", name) } } return nil } func validatePositiveIntFields(payload map[string]json.RawMessage, names ...string) error { for _, name := range names { var value int64 if err := decodeRequiredJSONField(payload, name, &value); err != nil { return err } if value < 1 { return fmt.Errorf("payload_json.%s must be at least 1", name) } } return nil } func decodeRequiredJSONField(payload map[string]json.RawMessage, name string, target any) error { raw, ok := payload[name] if !ok { return fmt.Errorf("payload_json.%s is required", name) } if err := decodeStrictJSON("decode payload_json."+name, string(raw), target, false); err != nil { return err } return nil } func decodeJSONObjectRaw(label string, raw string) (map[string]json.RawMessage, error) { var value map[string]json.RawMessage if err := decodeStrictJSON(label, raw, &value, false); err != nil { return nil, err } if value == nil { return nil, errors.New("payload_json must be a JSON object") } return value, nil } func decodeNormalizedJSONValue(label string, raw string) (any, error) { decoder := json.NewDecoder(bytes.NewBufferString(raw)) decoder.UseNumber() var value any if err := decoder.Decode(&value); err != nil { return nil, fmt.Errorf("%s: %w", label, err) } if err := decoder.Decode(&struct{}{}); err != io.EOF { if err == nil { return nil, fmt.Errorf("%s: unexpected trailing JSON input", label) } return nil, fmt.Errorf("%s: %w", label, err) } object, ok := value.(map[string]any) if !ok || object == nil { return nil, errors.New("payload_json must be a JSON object") } return value, nil } func decodeStrictJSON(label string, raw string, target any, useNumber bool) error { decoder := json.NewDecoder(bytes.NewBufferString(raw)) if useNumber { decoder.UseNumber() } if err := decoder.Decode(target); err != nil { return fmt.Errorf("%s: %w", label, err) } if err := decoder.Decode(&struct{}{}); err != io.EOF { if err == nil { return fmt.Errorf("%s: unexpected trailing JSON input", label) } return fmt.Errorf("%s: %w", label, err) } return nil } func validateTimestamp(name string, value time.Time) error { if value.IsZero() { return fmt.Errorf("%s must not be zero", name) } if !value.Equal(value.UTC()) { return fmt.Errorf("%s must be UTC", name) } if !value.Equal(value.Truncate(time.Millisecond)) { return fmt.Errorf("%s must use millisecond precision", name) } return nil }