873 lines
26 KiB
Go
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
|
|
}
|