136 lines
4.0 KiB
Go
136 lines
4.0 KiB
Go
// Package malformedintent defines the operator-visible record used for
|
|
// malformed notification intents.
|
|
package malformedintent
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// FailureCode identifies one stable malformed-intent rejection reason.
|
|
type FailureCode string
|
|
|
|
const (
|
|
// FailureCodeInvalidIntent reports malformed top-level intent fields or an
|
|
// invalid normalized envelope.
|
|
FailureCodeInvalidIntent FailureCode = "invalid_intent"
|
|
|
|
// FailureCodeInvalidPayload reports malformed or schema-invalid
|
|
// `payload_json`.
|
|
FailureCodeInvalidPayload FailureCode = "invalid_payload"
|
|
|
|
// FailureCodeIdempotencyConflict reports a duplicate idempotency scope that
|
|
// conflicts with already accepted normalized content.
|
|
FailureCodeIdempotencyConflict FailureCode = "idempotency_conflict"
|
|
|
|
// FailureCodeRecipientNotFound reports that a user-targeted recipient user
|
|
// id could not be resolved through User Service.
|
|
FailureCodeRecipientNotFound FailureCode = "recipient_not_found"
|
|
)
|
|
|
|
// Entry stores one operator-visible malformed notification-intent record.
|
|
type Entry struct {
|
|
// StreamEntryID stores the Redis Stream entry identifier of the rejected
|
|
// intent.
|
|
StreamEntryID string
|
|
|
|
// NotificationType stores the optional raw notification type extracted from
|
|
// the rejected entry.
|
|
NotificationType string
|
|
|
|
// Producer stores the optional raw producer value extracted from the
|
|
// rejected entry.
|
|
Producer string
|
|
|
|
// IdempotencyKey stores the optional raw idempotency key extracted from the
|
|
// rejected entry.
|
|
IdempotencyKey string
|
|
|
|
// FailureCode stores the stable rejection classification.
|
|
FailureCode FailureCode
|
|
|
|
// FailureMessage stores the detailed validation or decode failure.
|
|
FailureMessage string
|
|
|
|
// RawFields stores the raw top-level stream fields captured for operator
|
|
// inspection.
|
|
RawFields map[string]any
|
|
|
|
// RecordedAt stores when the malformed intent was durably recorded.
|
|
RecordedAt time.Time
|
|
}
|
|
|
|
// IsKnown reports whether code belongs to the frozen malformed-intent
|
|
// rejection surface.
|
|
func (code FailureCode) IsKnown() bool {
|
|
switch code {
|
|
case FailureCodeInvalidIntent, FailureCodeInvalidPayload, FailureCodeIdempotencyConflict, FailureCodeRecipientNotFound:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Validate reports whether entry contains a complete malformed-intent record.
|
|
func (entry Entry) Validate() error {
|
|
if strings.TrimSpace(entry.StreamEntryID) == "" {
|
|
return fmt.Errorf("malformed intent stream entry id must not be empty")
|
|
}
|
|
if !entry.FailureCode.IsKnown() {
|
|
return fmt.Errorf("malformed intent failure code %q is unsupported", entry.FailureCode)
|
|
}
|
|
if strings.TrimSpace(entry.FailureMessage) == "" {
|
|
return fmt.Errorf("malformed intent failure message must not be empty")
|
|
}
|
|
if strings.TrimSpace(entry.FailureMessage) != entry.FailureMessage {
|
|
return fmt.Errorf("malformed intent failure message must not contain surrounding whitespace")
|
|
}
|
|
if entry.RawFields == nil {
|
|
return fmt.Errorf("malformed intent raw fields must not be nil")
|
|
}
|
|
if err := validateJSONObject("malformed intent raw fields", entry.RawFields); err != nil {
|
|
return err
|
|
}
|
|
if err := validateTimestamp("malformed intent recorded at", entry.RecordedAt); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateJSONObject(name string, value map[string]any) error {
|
|
payload, err := json.Marshal(value)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %w", name, err)
|
|
}
|
|
if string(payload) == "null" {
|
|
return fmt.Errorf("%s must encode as a JSON object", name)
|
|
}
|
|
|
|
var decoded map[string]any
|
|
if err := json.Unmarshal(payload, &decoded); err != nil {
|
|
return fmt.Errorf("%s: %w", name, err)
|
|
}
|
|
if decoded == nil {
|
|
return fmt.Errorf("%s must encode as a JSON object", name)
|
|
}
|
|
|
|
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
|
|
}
|