548 lines
20 KiB
Go
548 lines
20 KiB
Go
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
|
|
}
|
|
}
|