feat: notification service
This commit is contained in:
@@ -0,0 +1,547 @@
|
||||
package redisstate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"galaxy/notification/internal/api/intentstream"
|
||||
"galaxy/notification/internal/service/acceptintent"
|
||||
"galaxy/notification/internal/service/malformedintent"
|
||||
)
|
||||
|
||||
// StreamOffset stores the persisted progress of the plain-XREAD intent
|
||||
// consumer.
|
||||
type StreamOffset struct {
|
||||
// Stream stores the Redis Stream name.
|
||||
Stream string
|
||||
|
||||
// LastProcessedEntryID stores the last durably processed Redis Stream entry
|
||||
// identifier.
|
||||
LastProcessedEntryID string
|
||||
|
||||
// UpdatedAt stores when the offset record was last updated.
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// DeadLetterEntry stores one terminal route-publication failure recorded for
|
||||
// later operator inspection.
|
||||
type DeadLetterEntry struct {
|
||||
// NotificationID stores the owning notification identifier.
|
||||
NotificationID string
|
||||
|
||||
// RouteID stores the exhausted route identifier.
|
||||
RouteID string
|
||||
|
||||
// Channel stores the failed route channel.
|
||||
Channel intentstream.Channel
|
||||
|
||||
// RecipientRef stores the stable failed recipient slot identifier.
|
||||
RecipientRef string
|
||||
|
||||
// FinalAttemptCount stores how many publication attempts were consumed.
|
||||
FinalAttemptCount int
|
||||
|
||||
// MaxAttempts stores the configured retry budget for Channel.
|
||||
MaxAttempts int
|
||||
|
||||
// FailureClassification stores the stable classified failure reason.
|
||||
FailureClassification string
|
||||
|
||||
// FailureMessage stores the last failure detail.
|
||||
FailureMessage string
|
||||
|
||||
// CreatedAt stores when the route moved to dead_letter.
|
||||
CreatedAt time.Time
|
||||
|
||||
// RecoveryHint stores the optional operator-facing recovery hint.
|
||||
RecoveryHint string
|
||||
}
|
||||
|
||||
type notificationRecordJSON struct {
|
||||
NotificationID string `json:"notification_id"`
|
||||
NotificationType intentstream.NotificationType `json:"notification_type"`
|
||||
Producer intentstream.Producer `json:"producer"`
|
||||
AudienceKind intentstream.AudienceKind `json:"audience_kind"`
|
||||
RecipientUserIDs []string `json:"recipient_user_ids,omitempty"`
|
||||
PayloadJSON string `json:"payload_json"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
RequestFingerprint string `json:"request_fingerprint"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
OccurredAtMS int64 `json:"occurred_at_ms"`
|
||||
AcceptedAtMS int64 `json:"accepted_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
type notificationRouteJSON struct {
|
||||
NotificationID string `json:"notification_id"`
|
||||
RouteID string `json:"route_id"`
|
||||
Channel intentstream.Channel `json:"channel"`
|
||||
RecipientRef string `json:"recipient_ref"`
|
||||
Status acceptintent.RouteStatus `json:"status"`
|
||||
AttemptCount int `json:"attempt_count"`
|
||||
MaxAttempts int `json:"max_attempts"`
|
||||
NextAttemptAtMS *int64 `json:"next_attempt_at_ms,omitempty"`
|
||||
ResolvedEmail string `json:"resolved_email,omitempty"`
|
||||
ResolvedLocale string `json:"resolved_locale,omitempty"`
|
||||
LastErrorClassification string `json:"last_error_classification,omitempty"`
|
||||
LastErrorMessage string `json:"last_error_message,omitempty"`
|
||||
LastErrorAtMS *int64 `json:"last_error_at_ms,omitempty"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
PublishedAtMS *int64 `json:"published_at_ms,omitempty"`
|
||||
DeadLetteredAtMS *int64 `json:"dead_lettered_at_ms,omitempty"`
|
||||
SkippedAtMS *int64 `json:"skipped_at_ms,omitempty"`
|
||||
}
|
||||
|
||||
type idempotencyRecordJSON struct {
|
||||
Producer intentstream.Producer `json:"producer"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
NotificationID string `json:"notification_id"`
|
||||
RequestFingerprint string `json:"request_fingerprint"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
ExpiresAtMS int64 `json:"expires_at_ms"`
|
||||
}
|
||||
|
||||
type malformedIntentJSON struct {
|
||||
StreamEntryID string `json:"stream_entry_id"`
|
||||
NotificationType string `json:"notification_type,omitempty"`
|
||||
Producer string `json:"producer,omitempty"`
|
||||
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||
FailureCode malformedintent.FailureCode `json:"failure_code"`
|
||||
FailureMessage string `json:"failure_message"`
|
||||
RawFields map[string]any `json:"raw_fields_json"`
|
||||
RecordedAtMS int64 `json:"recorded_at_ms"`
|
||||
}
|
||||
|
||||
type streamOffsetJSON struct {
|
||||
Stream string `json:"stream"`
|
||||
LastProcessedEntryID string `json:"last_processed_entry_id"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
type deadLetterEntryJSON struct {
|
||||
NotificationID string `json:"notification_id"`
|
||||
RouteID string `json:"route_id"`
|
||||
Channel intentstream.Channel `json:"channel"`
|
||||
RecipientRef string `json:"recipient_ref"`
|
||||
FinalAttemptCount int `json:"final_attempt_count"`
|
||||
MaxAttempts int `json:"max_attempts"`
|
||||
FailureClassification string `json:"failure_classification"`
|
||||
FailureMessage string `json:"failure_message"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
RecoveryHint string `json:"recovery_hint,omitempty"`
|
||||
}
|
||||
|
||||
// MarshalNotification marshals one notification record into the strict JSON
|
||||
// representation owned by Notification Service.
|
||||
func MarshalNotification(record acceptintent.NotificationRecord) ([]byte, error) {
|
||||
if err := record.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("marshal notification record: %w", err)
|
||||
}
|
||||
|
||||
return marshalStrictJSON(notificationRecordJSON{
|
||||
NotificationID: record.NotificationID,
|
||||
NotificationType: record.NotificationType,
|
||||
Producer: record.Producer,
|
||||
AudienceKind: record.AudienceKind,
|
||||
RecipientUserIDs: append([]string(nil), record.RecipientUserIDs...),
|
||||
PayloadJSON: record.PayloadJSON,
|
||||
IdempotencyKey: record.IdempotencyKey,
|
||||
RequestFingerprint: record.RequestFingerprint,
|
||||
RequestID: record.RequestID,
|
||||
TraceID: record.TraceID,
|
||||
OccurredAtMS: unixMilli(record.OccurredAt),
|
||||
AcceptedAtMS: unixMilli(record.AcceptedAt),
|
||||
UpdatedAtMS: unixMilli(record.UpdatedAt),
|
||||
})
|
||||
}
|
||||
|
||||
// UnmarshalNotification unmarshals one strict JSON notification record.
|
||||
func UnmarshalNotification(payload []byte) (acceptintent.NotificationRecord, error) {
|
||||
var wire notificationRecordJSON
|
||||
if err := unmarshalStrictJSON(payload, &wire); err != nil {
|
||||
return acceptintent.NotificationRecord{}, fmt.Errorf("unmarshal notification record: %w", err)
|
||||
}
|
||||
|
||||
record := acceptintent.NotificationRecord{
|
||||
NotificationID: wire.NotificationID,
|
||||
NotificationType: wire.NotificationType,
|
||||
Producer: wire.Producer,
|
||||
AudienceKind: wire.AudienceKind,
|
||||
RecipientUserIDs: append([]string(nil), wire.RecipientUserIDs...),
|
||||
PayloadJSON: wire.PayloadJSON,
|
||||
IdempotencyKey: wire.IdempotencyKey,
|
||||
RequestFingerprint: wire.RequestFingerprint,
|
||||
RequestID: wire.RequestID,
|
||||
TraceID: wire.TraceID,
|
||||
OccurredAt: time.UnixMilli(wire.OccurredAtMS).UTC(),
|
||||
AcceptedAt: time.UnixMilli(wire.AcceptedAtMS).UTC(),
|
||||
UpdatedAt: time.UnixMilli(wire.UpdatedAtMS).UTC(),
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return acceptintent.NotificationRecord{}, fmt.Errorf("unmarshal notification record: %w", err)
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// MarshalRoute marshals one notification route into the strict JSON
|
||||
// representation owned by Notification Service.
|
||||
func MarshalRoute(route acceptintent.NotificationRoute) ([]byte, error) {
|
||||
if err := route.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("marshal notification route: %w", err)
|
||||
}
|
||||
|
||||
return marshalStrictJSON(notificationRouteJSON{
|
||||
NotificationID: route.NotificationID,
|
||||
RouteID: route.RouteID,
|
||||
Channel: route.Channel,
|
||||
RecipientRef: route.RecipientRef,
|
||||
Status: route.Status,
|
||||
AttemptCount: route.AttemptCount,
|
||||
MaxAttempts: route.MaxAttempts,
|
||||
NextAttemptAtMS: optionalUnixMilli(route.NextAttemptAt),
|
||||
ResolvedEmail: route.ResolvedEmail,
|
||||
ResolvedLocale: route.ResolvedLocale,
|
||||
LastErrorClassification: route.LastErrorClassification,
|
||||
LastErrorMessage: route.LastErrorMessage,
|
||||
LastErrorAtMS: optionalUnixMilli(route.LastErrorAt),
|
||||
CreatedAtMS: unixMilli(route.CreatedAt),
|
||||
UpdatedAtMS: unixMilli(route.UpdatedAt),
|
||||
PublishedAtMS: optionalUnixMilli(route.PublishedAt),
|
||||
DeadLetteredAtMS: optionalUnixMilli(route.DeadLetteredAt),
|
||||
SkippedAtMS: optionalUnixMilli(route.SkippedAt),
|
||||
})
|
||||
}
|
||||
|
||||
// UnmarshalRoute unmarshals one strict JSON notification route.
|
||||
func UnmarshalRoute(payload []byte) (acceptintent.NotificationRoute, error) {
|
||||
var wire notificationRouteJSON
|
||||
if err := unmarshalStrictJSON(payload, &wire); err != nil {
|
||||
return acceptintent.NotificationRoute{}, fmt.Errorf("unmarshal notification route: %w", err)
|
||||
}
|
||||
|
||||
route := acceptintent.NotificationRoute{
|
||||
NotificationID: wire.NotificationID,
|
||||
RouteID: wire.RouteID,
|
||||
Channel: wire.Channel,
|
||||
RecipientRef: wire.RecipientRef,
|
||||
Status: wire.Status,
|
||||
AttemptCount: wire.AttemptCount,
|
||||
MaxAttempts: wire.MaxAttempts,
|
||||
ResolvedEmail: wire.ResolvedEmail,
|
||||
ResolvedLocale: wire.ResolvedLocale,
|
||||
LastErrorClassification: wire.LastErrorClassification,
|
||||
LastErrorMessage: wire.LastErrorMessage,
|
||||
CreatedAt: time.UnixMilli(wire.CreatedAtMS).UTC(),
|
||||
UpdatedAt: time.UnixMilli(wire.UpdatedAtMS).UTC(),
|
||||
}
|
||||
if wire.NextAttemptAtMS != nil {
|
||||
route.NextAttemptAt = time.UnixMilli(*wire.NextAttemptAtMS).UTC()
|
||||
}
|
||||
if wire.LastErrorAtMS != nil {
|
||||
route.LastErrorAt = time.UnixMilli(*wire.LastErrorAtMS).UTC()
|
||||
}
|
||||
if wire.PublishedAtMS != nil {
|
||||
route.PublishedAt = time.UnixMilli(*wire.PublishedAtMS).UTC()
|
||||
}
|
||||
if wire.DeadLetteredAtMS != nil {
|
||||
route.DeadLetteredAt = time.UnixMilli(*wire.DeadLetteredAtMS).UTC()
|
||||
}
|
||||
if wire.SkippedAtMS != nil {
|
||||
route.SkippedAt = time.UnixMilli(*wire.SkippedAtMS).UTC()
|
||||
}
|
||||
if err := route.Validate(); err != nil {
|
||||
return acceptintent.NotificationRoute{}, fmt.Errorf("unmarshal notification route: %w", err)
|
||||
}
|
||||
|
||||
return route, nil
|
||||
}
|
||||
|
||||
// MarshalIdempotency marshals one idempotency record into the strict JSON
|
||||
// representation owned by Notification Service.
|
||||
func MarshalIdempotency(record acceptintent.IdempotencyRecord) ([]byte, error) {
|
||||
if err := record.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("marshal notification idempotency record: %w", err)
|
||||
}
|
||||
|
||||
return marshalStrictJSON(idempotencyRecordJSON{
|
||||
Producer: record.Producer,
|
||||
IdempotencyKey: record.IdempotencyKey,
|
||||
NotificationID: record.NotificationID,
|
||||
RequestFingerprint: record.RequestFingerprint,
|
||||
CreatedAtMS: unixMilli(record.CreatedAt),
|
||||
ExpiresAtMS: unixMilli(record.ExpiresAt),
|
||||
})
|
||||
}
|
||||
|
||||
// UnmarshalIdempotency unmarshals one strict JSON idempotency record.
|
||||
func UnmarshalIdempotency(payload []byte) (acceptintent.IdempotencyRecord, error) {
|
||||
var wire idempotencyRecordJSON
|
||||
if err := unmarshalStrictJSON(payload, &wire); err != nil {
|
||||
return acceptintent.IdempotencyRecord{}, fmt.Errorf("unmarshal notification idempotency record: %w", err)
|
||||
}
|
||||
|
||||
record := acceptintent.IdempotencyRecord{
|
||||
Producer: wire.Producer,
|
||||
IdempotencyKey: wire.IdempotencyKey,
|
||||
NotificationID: wire.NotificationID,
|
||||
RequestFingerprint: wire.RequestFingerprint,
|
||||
CreatedAt: time.UnixMilli(wire.CreatedAtMS).UTC(),
|
||||
ExpiresAt: time.UnixMilli(wire.ExpiresAtMS).UTC(),
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return acceptintent.IdempotencyRecord{}, fmt.Errorf("unmarshal notification idempotency record: %w", err)
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// MarshalDeadLetter marshals one dead-letter entry into the strict JSON
|
||||
// representation owned by Notification Service.
|
||||
func MarshalDeadLetter(entry DeadLetterEntry) ([]byte, error) {
|
||||
if err := entry.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("marshal dead letter entry: %w", err)
|
||||
}
|
||||
|
||||
return marshalStrictJSON(deadLetterEntryJSON{
|
||||
NotificationID: entry.NotificationID,
|
||||
RouteID: entry.RouteID,
|
||||
Channel: entry.Channel,
|
||||
RecipientRef: entry.RecipientRef,
|
||||
FinalAttemptCount: entry.FinalAttemptCount,
|
||||
MaxAttempts: entry.MaxAttempts,
|
||||
FailureClassification: entry.FailureClassification,
|
||||
FailureMessage: entry.FailureMessage,
|
||||
CreatedAtMS: unixMilli(entry.CreatedAt),
|
||||
RecoveryHint: entry.RecoveryHint,
|
||||
})
|
||||
}
|
||||
|
||||
// UnmarshalDeadLetter unmarshals one strict JSON dead-letter entry.
|
||||
func UnmarshalDeadLetter(payload []byte) (DeadLetterEntry, error) {
|
||||
var wire deadLetterEntryJSON
|
||||
if err := unmarshalStrictJSON(payload, &wire); err != nil {
|
||||
return DeadLetterEntry{}, fmt.Errorf("unmarshal dead letter entry: %w", err)
|
||||
}
|
||||
|
||||
entry := DeadLetterEntry{
|
||||
NotificationID: wire.NotificationID,
|
||||
RouteID: wire.RouteID,
|
||||
Channel: wire.Channel,
|
||||
RecipientRef: wire.RecipientRef,
|
||||
FinalAttemptCount: wire.FinalAttemptCount,
|
||||
MaxAttempts: wire.MaxAttempts,
|
||||
FailureClassification: wire.FailureClassification,
|
||||
FailureMessage: wire.FailureMessage,
|
||||
CreatedAt: time.UnixMilli(wire.CreatedAtMS).UTC(),
|
||||
RecoveryHint: wire.RecoveryHint,
|
||||
}
|
||||
if err := entry.Validate(); err != nil {
|
||||
return DeadLetterEntry{}, fmt.Errorf("unmarshal dead letter entry: %w", err)
|
||||
}
|
||||
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// MarshalMalformedIntent marshals one malformed-intent entry into the strict
|
||||
// JSON representation owned by Notification Service.
|
||||
func MarshalMalformedIntent(entry malformedintent.Entry) ([]byte, error) {
|
||||
if err := entry.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("marshal malformed intent: %w", err)
|
||||
}
|
||||
|
||||
return marshalStrictJSON(malformedIntentJSON{
|
||||
StreamEntryID: entry.StreamEntryID,
|
||||
NotificationType: entry.NotificationType,
|
||||
Producer: entry.Producer,
|
||||
IdempotencyKey: entry.IdempotencyKey,
|
||||
FailureCode: entry.FailureCode,
|
||||
FailureMessage: entry.FailureMessage,
|
||||
RawFields: cloneJSONObject(entry.RawFields),
|
||||
RecordedAtMS: unixMilli(entry.RecordedAt),
|
||||
})
|
||||
}
|
||||
|
||||
// UnmarshalMalformedIntent unmarshals one strict JSON malformed-intent entry.
|
||||
func UnmarshalMalformedIntent(payload []byte) (malformedintent.Entry, error) {
|
||||
var wire malformedIntentJSON
|
||||
if err := unmarshalStrictJSON(payload, &wire); err != nil {
|
||||
return malformedintent.Entry{}, fmt.Errorf("unmarshal malformed intent: %w", err)
|
||||
}
|
||||
|
||||
entry := malformedintent.Entry{
|
||||
StreamEntryID: wire.StreamEntryID,
|
||||
NotificationType: wire.NotificationType,
|
||||
Producer: wire.Producer,
|
||||
IdempotencyKey: wire.IdempotencyKey,
|
||||
FailureCode: wire.FailureCode,
|
||||
FailureMessage: wire.FailureMessage,
|
||||
RawFields: cloneJSONObject(wire.RawFields),
|
||||
RecordedAt: time.UnixMilli(wire.RecordedAtMS).UTC(),
|
||||
}
|
||||
if err := entry.Validate(); err != nil {
|
||||
return malformedintent.Entry{}, fmt.Errorf("unmarshal malformed intent: %w", err)
|
||||
}
|
||||
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// MarshalStreamOffset marshals one stream-offset record into the strict JSON
|
||||
// representation owned by Notification Service.
|
||||
func MarshalStreamOffset(offset StreamOffset) ([]byte, error) {
|
||||
if err := offset.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("marshal stream offset: %w", err)
|
||||
}
|
||||
|
||||
return marshalStrictJSON(streamOffsetJSON{
|
||||
Stream: offset.Stream,
|
||||
LastProcessedEntryID: offset.LastProcessedEntryID,
|
||||
UpdatedAtMS: unixMilli(offset.UpdatedAt),
|
||||
})
|
||||
}
|
||||
|
||||
// UnmarshalStreamOffset unmarshals one strict JSON stream-offset record.
|
||||
func UnmarshalStreamOffset(payload []byte) (StreamOffset, error) {
|
||||
var wire streamOffsetJSON
|
||||
if err := unmarshalStrictJSON(payload, &wire); err != nil {
|
||||
return StreamOffset{}, fmt.Errorf("unmarshal stream offset: %w", err)
|
||||
}
|
||||
|
||||
offset := StreamOffset{
|
||||
Stream: wire.Stream,
|
||||
LastProcessedEntryID: wire.LastProcessedEntryID,
|
||||
UpdatedAt: time.UnixMilli(wire.UpdatedAtMS).UTC(),
|
||||
}
|
||||
if err := offset.Validate(); err != nil {
|
||||
return StreamOffset{}, fmt.Errorf("unmarshal stream offset: %w", err)
|
||||
}
|
||||
|
||||
return offset, nil
|
||||
}
|
||||
|
||||
// Validate reports whether offset contains a complete persisted consumer
|
||||
// progress record.
|
||||
func (offset StreamOffset) Validate() error {
|
||||
if offset.Stream == "" {
|
||||
return fmt.Errorf("stream offset stream must not be empty")
|
||||
}
|
||||
if offset.LastProcessedEntryID == "" {
|
||||
return fmt.Errorf("stream offset last processed entry id must not be empty")
|
||||
}
|
||||
if offset.UpdatedAt.IsZero() {
|
||||
return fmt.Errorf("stream offset updated at must not be zero")
|
||||
}
|
||||
if !offset.UpdatedAt.Equal(offset.UpdatedAt.UTC()) {
|
||||
return fmt.Errorf("stream offset updated at must be UTC")
|
||||
}
|
||||
if !offset.UpdatedAt.Equal(offset.UpdatedAt.Truncate(time.Millisecond)) {
|
||||
return fmt.Errorf("stream offset updated at must use millisecond precision")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate reports whether entry contains a complete dead-letter record.
|
||||
func (entry DeadLetterEntry) Validate() error {
|
||||
if entry.NotificationID == "" {
|
||||
return fmt.Errorf("dead letter entry notification id must not be empty")
|
||||
}
|
||||
if entry.RouteID == "" {
|
||||
return fmt.Errorf("dead letter entry route id must not be empty")
|
||||
}
|
||||
if !entry.Channel.IsKnown() {
|
||||
return fmt.Errorf("dead letter entry channel %q is unsupported", entry.Channel)
|
||||
}
|
||||
if entry.RecipientRef == "" {
|
||||
return fmt.Errorf("dead letter entry recipient ref must not be empty")
|
||||
}
|
||||
if entry.FinalAttemptCount <= 0 {
|
||||
return fmt.Errorf("dead letter entry final attempt count must be positive")
|
||||
}
|
||||
if entry.MaxAttempts <= 0 {
|
||||
return fmt.Errorf("dead letter entry max attempts must be positive")
|
||||
}
|
||||
if entry.FailureClassification == "" {
|
||||
return fmt.Errorf("dead letter entry failure classification must not be empty")
|
||||
}
|
||||
if entry.FailureMessage == "" {
|
||||
return fmt.Errorf("dead letter entry failure message must not be empty")
|
||||
}
|
||||
if entry.CreatedAt.IsZero() {
|
||||
return fmt.Errorf("dead letter entry created at must not be zero")
|
||||
}
|
||||
if !entry.CreatedAt.Equal(entry.CreatedAt.UTC()) {
|
||||
return fmt.Errorf("dead letter entry created at must be UTC")
|
||||
}
|
||||
if !entry.CreatedAt.Equal(entry.CreatedAt.Truncate(time.Millisecond)) {
|
||||
return fmt.Errorf("dead letter entry created at must use millisecond precision")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func marshalStrictJSON(value any) ([]byte, error) {
|
||||
return json.Marshal(value)
|
||||
}
|
||||
|
||||
func unmarshalStrictJSON(payload []byte, target any) error {
|
||||
decoder := json.NewDecoder(bytes.NewBuffer(payload))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return fmt.Errorf("unexpected trailing JSON input")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func unixMilli(value time.Time) int64 {
|
||||
return value.UTC().UnixMilli()
|
||||
}
|
||||
|
||||
func optionalUnixMilli(value time.Time) *int64 {
|
||||
if value.IsZero() {
|
||||
return nil
|
||||
}
|
||||
millis := unixMilli(value)
|
||||
return &millis
|
||||
}
|
||||
|
||||
func cloneJSONObject(value map[string]any) map[string]any {
|
||||
if value == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
cloned := make(map[string]any, len(value))
|
||||
for key, raw := range value {
|
||||
cloned[key] = cloneJSONValue(raw)
|
||||
}
|
||||
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneJSONValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
return cloneJSONObject(typed)
|
||||
case []any:
|
||||
cloned := make([]any, len(typed))
|
||||
for index, item := range typed {
|
||||
cloned[index] = cloneJSONValue(item)
|
||||
}
|
||||
return cloned
|
||||
default:
|
||||
return typed
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user