Files
galaxy-game/pkg/notificationintent/intent.go
T
2026-04-22 08:49:45 +02:00

873 lines
26 KiB
Go

// 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"
// 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"
)
// 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,
NotificationTypeLobbyInviteCreated,
NotificationTypeLobbyInviteRedeemed,
NotificationTypeLobbyInviteExpired:
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,
NotificationTypeLobbyInviteCreated,
NotificationTypeLobbyInviteRedeemed,
NotificationTypeLobbyInviteExpired:
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:
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 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")
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
}